From 5f72264b06b123fa8c9f52d8c62aaf5d7ead8065 Mon Sep 17 00:00:00 2001 From: Tony Date: Fri, 12 Jun 2026 20:03:58 +0800 Subject: [PATCH] feat: add organization management features and password reset functionality - Introduced a new section for managing organizations, including creating organizations and adding users to them. - Added a password reset modal and functionality to request a password reset link. - Updated the settings page to include personal settings for changing passwords and admin settings for configuring registration options. - Enhanced the app detail view with tabs for releases, insights, collaborators, tracks, and settings. - Improved user management with admin capabilities to verify user emails and change user roles. - Updated navigation and UI elements to accommodate new features and improve user experience. --- .env.example | 15 + README.md | 57 +- cmd/server/main.go | 22 +- internal/api/handlers/admin.go | 143 ++++- internal/api/handlers/apps.go | 222 ++++++- internal/api/handlers/auth.go | 346 ++++++++++- internal/api/handlers/releases.go | 17 +- internal/api/handlers/router.go | 31 +- internal/api/handlers/storage_handler.go | 20 +- internal/api/middleware/auth.go | 16 +- internal/config/config.go | 30 + internal/db/db.go | 169 +++++- internal/db/migrations/001_initial_schema.sql | 34 ++ internal/db/sqlite.go | 359 +++++++++-- internal/db/store.go | 65 +- internal/email/email.go | 59 ++ internal/models/models.go | 98 +-- web/index.html | 168 ++++- web/js/app.js | 573 +++++++++++++++++- 19 files changed, 2236 insertions(+), 208 deletions(-) create mode 100644 internal/email/email.go diff --git a/.env.example b/.env.example index 82ce242..945eb55 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,21 @@ STORAGE_LOCAL_DIR=data/storage # --- Authentication --- JWT_SECRET=change-me-in-production-use-a-long-random-string +# --- Bootstrap admin --- +# Created automatically if missing. This account must change password on first login. +DEFAULT_ADMIN_EMAIL=admin@example.com +DEFAULT_ADMIN_PASSWORD=admin123 +DEFAULT_ADMIN_NAME=Administrator + +# --- Account email --- +# SMTP is used for email verification and password reset links. +# If SMTP_HOST is empty, links are logged to the server console for local/dev use. +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USERNAME= +# SMTP_PASSWORD= +# SMTP_FROM=shorebird@example.com + # --- Redis (optional) --- # REDIS_ADDR=localhost:6379 # REDIS_PASSWORD= diff --git a/README.md b/README.md index f990fd8..3b25b4e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A self-hosted replacement for the Shorebird CodePush API server (`api.shorebird. ## Quick Start (Zero Dependencies) -By default the server uses **SQLite** + **local filesystem** — no Docker, +By default the server uses **SQLite** + **local filesystem** - no Docker, no Postgres, no MinIO needed. ### Prerequisites @@ -21,7 +21,7 @@ and file storage are auto-created under the `data/` directory. ### 2. Open the Web Dashboard -Navigate to **http://localhost:8080** → Register → Start managing apps. +Navigate to **http://localhost:8080** -> Register -> Start managing apps. ### 3. (Optional) Use PostgreSQL + MinIO @@ -57,6 +57,30 @@ All settings via environment variables. See `.env.example` for the full list. | `JWT_SECRET` | *(insecure default)* | Set in production! | | `SERVER_PORT` | `8080` | HTTP port | | `SERVER_BASE_URL` | `http://localhost:8080` | Public URL for download links | +| `SMTP_HOST` | *(empty)* | SMTP host for verification/reset emails; empty logs links | +| `SMTP_PORT` | `587` | SMTP port | +| `SMTP_FROM` | `shorebird@localhost` | From address for account emails | + +### Account Security and SSO + +New password registrations must verify email before web, CLI, or API access is allowed. Configure SMTP in `.env`; without SMTP, verification and reset links are printed to the server log for development. + +On startup, the server ensures a bootstrap admin exists: + +- Email: `admin@example.com` +- Password: `admin123` + +Override with `DEFAULT_ADMIN_EMAIL`, `DEFAULT_ADMIN_PASSWORD`, and `DEFAULT_ADMIN_NAME`. The bootstrap admin is forced to set a new password on first login. + +Admins can manage these settings in the dashboard: + +- Casdoor SSO endpoint, app ID, app secret, and organization. +- Password registration enabled/disabled. +- SSO registration enabled/disabled. +- SSO-only registration. +- User email verification and global admin grants. + +The first registered account becomes a global admin. Existing SQLite users are marked verified during migration to avoid locking out current installs. ### 6. Configure Shorebird CLI @@ -70,7 +94,11 @@ Then use `shorebird init`, `shorebird release`, and `shorebird patch` as normal. ### 7. Configure device-side `shorebird.yaml` -Add to your Flutter app's `shorebird.yaml`: +Add your server URL to the Flutter app's `shorebird.yaml`. The CLI +environment variables make `shorebird release` and `shorebird patch` talk to +your server, but the packaged app also needs `base_url` so the runtime updater +checks the same server. + ```yaml app_id: base_url: http://your-server.com:8080 @@ -84,20 +112,31 @@ base_url: http://your-server.com:8080 | POST | `/auth/register` | Register a new user | | POST | `/auth/token` | Login (get JWT) | | POST | `/auth/refresh` | Refresh JWT | +| GET | `/auth/public-settings` | Registration/SSO capabilities for login UI | +| POST | `/auth/verify-email/request` | Send verification email | +| GET | `/auth/verify-email` | Verify email by token | +| POST | `/auth/password-reset/request` | Send password reset email | +| GET | `/auth/password-reset` | Password reset form | +| POST | `/auth/password-reset` | Complete password reset | +| GET | `/auth/sso/login` | Start Casdoor OAuth login | +| GET | `/auth/sso/callback` | Casdoor OAuth callback | ### API v1 (JWT required unless noted) | Method | Path | Description | |--------|------|-------------| | GET | `/api/v1/users/me` | Get current user | +| PATCH | `/api/v1/users/me/password` | Change current user's password | | POST | `/api/v1/users` | Create user | | GET | `/api/v1/apps` | List apps | | POST | `/api/v1/apps` | Create app | | DELETE | `/api/v1/apps/{appId}` | Delete app | +| PATCH | `/api/v1/apps/{appId}/transfer` | Transfer app ownership to another organization | | POST | `/api/v1/apps/{appId}/channels` | Create channel | | GET | `/api/v1/apps/{appId}/channels` | List channels | | POST | `/api/v1/apps/{appId}/releases` | Create release | | GET | `/api/v1/apps/{appId}/releases` | List releases | | PATCH | `/api/v1/apps/{appId}/releases/{releaseId}` | Update release | +| DELETE | `/api/v1/apps/{appId}/releases/{releaseId}` | Delete release | | POST | `/api/v1/apps/{appId}/releases/{releaseId}/artifacts` | Upload release artifact | | GET | `/api/v1/apps/{appId}/releases/{releaseId}/artifacts` | Get release artifacts | | POST | `/api/v1/apps/{appId}/patches` | Create patch | @@ -105,6 +144,9 @@ base_url: http://your-server.com:8080 | GET | `/api/v1/apps/{appId}/releases/{releaseId}/patches` | List patches | | POST | `/api/v1/apps/{appId}/patches/promote` | Promote patch to channel | | GET | `/api/v1/organizations` | List organizations | +| POST | `/api/v1/organizations` | Create organization | +| GET | `/api/v1/organizations/{orgId}/users` | List organization users | +| POST | `/api/v1/organizations/{orgId}/users` | Add/update organization user role | | **POST** | **`/api/v1/patches/check`** | **Device patch check (public)** | | **POST** | **`/api/v1/patches/events`** | **Device patch event (public)** | | GET | `/api/v1/diagnostics/gcp_upload` | Speed test (stub) | @@ -117,6 +159,15 @@ base_url: http://your-server.com:8080 | GET | `/api/v1/admin/patches/{patchId}/target-devices` | List targeted devices | | DELETE | `/api/v1/admin/patches/{patchId}/target-devices/{clientId}` | Remove device restriction | +### Admin: Users and Settings +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/admin/users` | List users | +| POST | `/api/v1/admin/users/{userId}/verify-email` | Force verify a user email | +| POST | `/api/v1/admin/users/{userId}/admin` | Grant or revoke global admin | +| GET | `/api/v1/admin/settings` | Get registration and SSO settings | +| PUT | `/api/v1/admin/settings` | Update registration and SSO settings | + ## Environment Variables See `.env.example` for all available configuration options. diff --git a/cmd/server/main.go b/cmd/server/main.go index 134bf78..3233505 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -14,6 +14,7 @@ import ( "github.com/shorebird-server/internal/auth" "github.com/shorebird-server/internal/config" "github.com/shorebird-server/internal/db" + "github.com/shorebird-server/internal/email" "github.com/shorebird-server/internal/storage" ) @@ -36,8 +37,27 @@ func main() { defer database.Close() log.Printf("Database: %s (%s)", cfg.DB.Driver, cfg.DB.Path) + adminHash, err := auth.HashPassword(cfg.Admin.Password) + if err != nil { + log.Fatalf("Failed to hash default admin password: %v", err) + } + createdAdmin, err := database.EnsureDefaultAdmin(ctx, cfg.Admin.Email, cfg.Admin.Name, adminHash) + if err != nil { + log.Fatalf("Failed to ensure default admin account: %v", err) + } + if createdAdmin { + log.Printf("Created default admin account %s with default password; password change required at first login", cfg.Admin.Email) + } + // --- Auth --- authService := auth.NewService(cfg.Auth.JWTSecret, cfg.Auth.TokenDuration) + mailer := email.NewMailer(email.Config{ + Host: cfg.Email.Host, + Port: cfg.Email.Port, + Username: cfg.Email.Username, + Password: cfg.Email.Password, + From: cfg.Email.From, + }) // --- Storage --- store, err := storage.NewStore(storage.Config{ @@ -57,7 +77,7 @@ func main() { log.Printf("Storage: %s", store.BackendName()) // --- Router --- - router := handlers.NewRouter(authService, database, store) + router := handlers.NewRouter(authService, database, store, mailer, cfg.Server.BaseURL) // --- HTTP Server --- addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port) diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index cdaa2ee..daece12 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -61,8 +61,8 @@ func (h *AdminHandler) GetTargetDevices(w http.ResponseWriter, r *http.Request) } respondJSON(w, http.StatusOK, map[string]interface{}{ - "patch_id": patchID, - "client_ids": devices, + "patch_id": patchID, + "client_ids": devices, }) } @@ -78,9 +78,7 @@ func (h *AdminHandler) GetPatchEvents(w http.ResponseWriter, r *http.Request) { // ListUsers handles GET /api/v1/admin/users (admin convenience endpoint) func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) { - claims := middleware.GetClaims(r) - if claims == nil { - respondError(w, http.StatusUnauthorized, "Unauthorized", nil) + if !h.requireAdmin(w, r) { return } @@ -88,18 +86,19 @@ func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) { if err != nil { respondJSON(w, http.StatusOK, map[string]interface{}{ "message": "Admin endpoints available", - "user": claims.Email, "users": []interface{}{}, }) return } type userEntry struct { - ID int `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Role string `json:"role"` - CreatedAt string `json:"created_at"` + ID int `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + CreatedAt string `json:"created_at"` + EmailVerified bool `json:"email_verified"` + IsAdmin bool `json:"is_admin"` } result := make([]userEntry, 0, len(users)) @@ -109,11 +108,13 @@ func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) { role = "member" } result = append(result, userEntry{ - ID: u.ID, - Email: u.Email, - Name: u.Name, - Role: role, - CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"), + ID: u.ID, + Email: u.Email, + Name: u.Name, + Role: role, + CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"), + EmailVerified: u.EmailVerified, + IsAdmin: u.IsAdmin, }) } @@ -121,3 +122,113 @@ func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) { "users": result, }) } + +func (h *AdminHandler) VerifyUserEmail(w http.ResponseWriter, r *http.Request) { + if !h.requireAdmin(w, r) { + return + } + userID, err := strconv.Atoi(chi.URLParam(r, "userId")) + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid user ID", nil) + return + } + if err := h.DB.MarkUserEmailVerified(r.Context(), userID); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to verify user email", nil) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *AdminHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) { + if !h.requireAdmin(w, r) { + return + } + userID, err := strconv.Atoi(chi.URLParam(r, "userId")) + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid user ID", nil) + return + } + var req struct { + IsAdmin bool `json:"is_admin"` + } + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body", nil) + return + } + if err := h.DB.SetUserAdmin(r.Context(), userID, req.IsAdmin); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to update admin status", nil) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *AdminHandler) GetSettings(w http.ResponseWriter, r *http.Request) { + if !h.requireAdmin(w, r) { + return + } + settings, err := h.DB.ListSettings(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to load settings", nil) + return + } + respondJSON(w, http.StatusOK, map[string]interface{}{"settings": redactedSettings(settings)}) +} + +func (h *AdminHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) { + if !h.requireAdmin(w, r) { + return + } + var req map[string]string + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body", nil) + return + } + allowed := map[string]bool{ + "registration_enabled": true, + "sso_registration_enabled": true, + "sso_only_registration": true, + "casdoor_endpoint": true, + "casdoor_client_id": true, + "casdoor_client_secret": true, + "casdoor_organization": true, + } + for key, value := range req { + if !allowed[key] { + continue + } + if key == "casdoor_client_secret" && value == "" { + continue + } + if err := h.DB.SetSetting(r.Context(), key, value); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to update settings", nil) + return + } + } + h.GetSettings(w, r) +} + +func (h *AdminHandler) requireAdmin(w http.ResponseWriter, r *http.Request) bool { + claims := middleware.GetClaims(r) + if claims == nil { + respondError(w, http.StatusUnauthorized, "Unauthorized", nil) + return false + } + user, err := h.DB.GetUserByID(r.Context(), claims.UserID) + if err != nil || !user.IsAdmin { + respondError(w, http.StatusForbidden, "Admin access required", nil) + return false + } + return true +} + +func redactedSettings(settings map[string]string) map[string]string { + out := map[string]string{} + for k, v := range settings { + if k == "casdoor_client_secret" && v != "" { + out[k] = "********" + continue + } + out[k] = v + } + return out +} diff --git a/internal/api/handlers/apps.go b/internal/api/handlers/apps.go index 68f48d3..abf49ba 100644 --- a/internal/api/handlers/apps.go +++ b/internal/api/handlers/apps.go @@ -2,10 +2,13 @@ package handlers import ( "net/http" + "strconv" + "strings" "time" "github.com/go-chi/chi/v5" "github.com/shorebird-server/internal/api/middleware" + "github.com/shorebird-server/internal/auth" "github.com/shorebird-server/internal/db" "github.com/shorebird-server/internal/models" ) @@ -22,7 +25,8 @@ func intPtr(i int) *int { // UserHandler handles user-related API endpoints. type UserHandler struct { - DB db.Store + DB db.Store + BaseURL string } // GetCurrentUser handles GET /api/v1/users/me @@ -43,13 +47,52 @@ func (h *UserHandler) GetCurrentUser(w http.ResponseWriter, r *http.Request) { "id": user.ID, "email": user.Email, "display_name": user.Name, - "jwt_issuer": "http://localhost:8080/auth", + "email_verified": user.EmailVerified, + "is_admin": user.IsAdmin, + "must_change_password": user.MustChangePassword, + "jwt_issuer": strings.TrimRight(h.BaseURL, "/") + "/auth", "has_active_subscription": true, "stripe_customer_id": nil, "patch_overage_limit": nil, }) } +// UpdatePassword handles PATCH /api/v1/users/me/password +func (h *UserHandler) UpdatePassword(w http.ResponseWriter, r *http.Request) { + claims := middleware.GetClaims(r) + if claims == nil { + respondError(w, http.StatusUnauthorized, "Unauthorized", nil) + return + } + var req struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body", nil) + return + } + user, err := h.DB.GetUserByID(r.Context(), claims.UserID) + if err != nil { + respondError(w, http.StatusNotFound, "User not found", nil) + return + } + if !user.MustChangePassword && auth.CheckPassword(user.PasswordHash, req.CurrentPassword) != nil { + respondError(w, http.StatusUnauthorized, "Current password is incorrect", nil) + return + } + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to hash password", nil) + return + } + if err := h.DB.UpdateUserPassword(r.Context(), user.ID, hash); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to update password", nil) + return + } + w.WriteHeader(http.StatusNoContent) +} + // CreateUser handles POST /api/v1/users func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { claims := middleware.GetClaims(r) @@ -103,13 +146,14 @@ func (h *AppHandler) GetApps(w http.ResponseWriter, r *http.Request) { var result []models.AppMetadata for _, a := range apps { result = append(result, models.AppMetadata{ - AppID: a.ID.String(), - DisplayName: a.DisplayName, - CreatedAt: a.CreatedAt.Time, - UpdatedAt: a.UpdatedAt.Time, - Platforms: []string{}, - LatestReleases: map[string]models.LatestRelease{}, - PendingReleases: map[string]models.PendingRelease{}, + AppID: a.ID.String(), + OrganizationID: a.OrganizationID, + DisplayName: a.DisplayName, + CreatedAt: a.CreatedAt.Time, + UpdatedAt: a.UpdatedAt.Time, + Platforms: []string{}, + LatestReleases: map[string]models.LatestRelease{}, + PendingReleases: map[string]models.PendingRelease{}, }) } if result == nil { @@ -149,6 +193,10 @@ func (h *AppHandler) CreateApp(w http.ResponseWriter, r *http.Request) { req.OrganizationID = orgs[0].OrgID } } + if !h.canManageOrganization(r, claims.UserID, req.OrganizationID) { + respondError(w, http.StatusForbidden, "Organization admin access required", nil) + return + } app, err := h.DB.CreateApp(r.Context(), req.OrganizationID, req.DisplayName) if err != nil { @@ -172,12 +220,26 @@ func (h *AppHandler) CreateApp(w http.ResponseWriter, r *http.Request) { // DeleteApp handles DELETE /api/v1/apps/{appId} func (h *AppHandler) DeleteApp(w http.ResponseWriter, r *http.Request) { + claims := middleware.GetClaims(r) + if claims == nil { + respondError(w, http.StatusUnauthorized, "Unauthorized", nil) + return + } appID := chi.URLParam(r, "appId") id, err := parseUUID(appID) if err != nil { respondError(w, http.StatusBadRequest, "Invalid app ID", nil) return } + app, err := h.DB.GetAppByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusNotFound, "App not found", nil) + return + } + if !h.canManageOrganization(r, claims.UserID, app.OrganizationID) { + respondError(w, http.StatusForbidden, "Organization admin access required", nil) + return + } if err := h.DB.DeleteApp(r.Context(), id); err != nil { respondError(w, http.StatusInternalServerError, "Failed to delete app", nil) @@ -187,6 +249,50 @@ func (h *AppHandler) DeleteApp(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (h *AppHandler) TransferApp(w http.ResponseWriter, r *http.Request) { + claims := middleware.GetClaims(r) + if claims == nil { + respondError(w, http.StatusUnauthorized, "Unauthorized", nil) + return + } + appID := chi.URLParam(r, "appId") + id, err := parseUUID(appID) + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid app ID", nil) + return + } + var req struct { + OrganizationID int `json:"organization_id"` + } + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body", nil) + return + } + app, err := h.DB.GetAppByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusNotFound, "App not found", nil) + return + } + if !h.canManageOrganization(r, claims.UserID, app.OrganizationID) || !h.canManageOrganization(r, claims.UserID, req.OrganizationID) { + respondError(w, http.StatusForbidden, "Organization admin access required", nil) + return + } + if err := h.DB.UpdateAppOrganization(r.Context(), id, req.OrganizationID); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to transfer app", strPtr(err.Error())) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *AppHandler) canManageOrganization(r *http.Request, userID, orgID int) bool { + user, _ := h.DB.GetUserByID(r.Context(), userID) + if user != nil && user.IsAdmin { + return true + } + role, err := h.DB.GetOrganizationRole(r.Context(), userID, orgID) + return err == nil && role == "admin" +} + // ChannelHandler handles channel-related API endpoints. type ChannelHandler struct { DB db.Store @@ -286,3 +392,101 @@ func (h *OrganizationHandler) GetOrganizations(w http.ResponseWriter, r *http.Re respondJSON(w, http.StatusOK, map[string]interface{}{"organizations": result}) } + +func (h *OrganizationHandler) CreateOrganization(w http.ResponseWriter, r *http.Request) { + claims := middleware.GetClaims(r) + if claims == nil { + respondError(w, http.StatusUnauthorized, "Unauthorized", nil) + return + } + var req struct { + Name string `json:"name"` + Type string `json:"organization_type"` + } + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body", nil) + return + } + if req.Type == "" { + req.Type = "team" + } + orgID, err := h.DB.CreateOrganization(r.Context(), req.Name, req.Type) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to create organization", strPtr(err.Error())) + return + } + if err := h.DB.AddUserToOrganization(r.Context(), claims.UserID, orgID, "admin"); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to add organization owner", nil) + return + } + respondJSON(w, http.StatusCreated, map[string]interface{}{"id": orgID, "name": req.Name, "organization_type": req.Type}) +} + +func (h *OrganizationHandler) ListOrganizationUsers(w http.ResponseWriter, r *http.Request) { + orgID, ok := h.requireOrgAdmin(w, r) + if !ok { + return + } + users, err := h.DB.ListOrganizationUsers(r.Context(), orgID) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to fetch organization users", nil) + return + } + result := make([]map[string]interface{}, 0, len(users)) + for _, u := range users { + result = append(result, map[string]interface{}{ + "id": u.ID, "email": u.Email, "name": u.Name, "role": u.Role, + "email_verified": u.EmailVerified, "is_admin": u.IsAdmin, + "created_at": u.CreatedAt.Format("2006-01-02T15:04:05Z"), + }) + } + respondJSON(w, http.StatusOK, map[string]interface{}{"users": result}) +} + +func (h *OrganizationHandler) AddOrganizationUser(w http.ResponseWriter, r *http.Request) { + orgID, ok := h.requireOrgAdmin(w, r) + if !ok { + return + } + var req struct { + Email string `json:"email"` + Role string `json:"role"` + } + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body", nil) + return + } + if req.Role == "" { + req.Role = "member" + } + user, err := h.DB.GetUserByEmail(r.Context(), req.Email) + if err != nil { + respondError(w, http.StatusNotFound, "User not found", strPtr("Create the user first, then add them to the organization.")) + return + } + if err := h.DB.AddUserToOrganization(r.Context(), user.ID, orgID, req.Role); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to add user to organization", nil) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *OrganizationHandler) requireOrgAdmin(w http.ResponseWriter, r *http.Request) (int, bool) { + claims := middleware.GetClaims(r) + if claims == nil { + respondError(w, http.StatusUnauthorized, "Unauthorized", nil) + return 0, false + } + orgID, err := strconv.Atoi(chi.URLParam(r, "orgId")) + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid organization ID", nil) + return 0, false + } + user, _ := h.DB.GetUserByID(r.Context(), claims.UserID) + role, err := h.DB.GetOrganizationRole(r.Context(), claims.UserID, orgID) + if err != nil || (role != "admin" && (user == nil || !user.IsAdmin)) { + respondError(w, http.StatusForbidden, "Organization admin access required", nil) + return 0, false + } + return orgID, true +} diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go index 181bd57..47ae57e 100644 --- a/internal/api/handlers/auth.go +++ b/internal/api/handlers/auth.go @@ -2,9 +2,14 @@ package handlers import ( "crypto/rand" + "crypto/sha256" + "encoding/base64" "encoding/hex" + "encoding/json" "fmt" + "io" "net/http" + "net/url" "strings" "sync" "time" @@ -12,6 +17,7 @@ import ( "github.com/shorebird-server/internal/api/middleware" "github.com/shorebird-server/internal/auth" "github.com/shorebird-server/internal/db" + "github.com/shorebird-server/internal/email" "github.com/shorebird-server/internal/models" ) @@ -74,6 +80,13 @@ type oauthTokenResponse struct { type AuthHandler struct { DB db.Store AuthService *auth.Service + Mailer *email.Mailer + BaseURL string +} + +type casdoorProfile struct { + Email string + Name string } // LoginPage handles GET /auth/login — OAuth HTML login form. @@ -88,12 +101,16 @@ func (h *AuthHandler) LoginPage(w http.ResponseWriter, r *http.Request) { email := r.FormValue("email") password := r.FormValue("password") if email == "" || password == "" { - serveLoginHTML(w, continueURL, "Email and password are required.") + serveLoginHTML(w, continueURL, "Email and password are required.", h.ssoEnabled(r)) return } user, err := h.DB.GetUserByEmail(r.Context(), email) if err != nil || auth.CheckPassword(user.PasswordHash, password) != nil { - serveLoginHTML(w, continueURL, "Invalid email or password.") + serveLoginHTML(w, continueURL, "Invalid email or password.", h.ssoEnabled(r)) + return + } + if !user.EmailVerified { + serveLoginHTML(w, continueURL, "Verify your email address before signing in.", h.ssoEnabled(r)) return } code := globalCodeStore.set(user.Email) @@ -101,7 +118,7 @@ func (h *AuthHandler) LoginPage(w http.ResponseWriter, r *http.Request) { return } - serveLoginHTML(w, continueURL, "") + serveLoginHTML(w, continueURL, "", h.ssoEnabled(r)) } // Login handles POST /auth/token — supports OAuth form-encoded + JSON body. @@ -133,6 +150,10 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "User not found", nil) return } + if !user.EmailVerified { + respondError(w, http.StatusForbidden, "Email verification required", strPtr("Verify your email address before signing in.")) + return + } h.writeOAuthTokenResponse(w, user.ID, user.Email) case "refresh_token": @@ -146,6 +167,11 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusBadRequest, "Invalid refresh token", nil) return } + user, err := h.DB.GetUserByID(r.Context(), claims.UserID) + if err != nil || !user.EmailVerified { + respondError(w, http.StatusForbidden, "Email verification required", nil) + return + } h.writeOAuthTokenResponse(w, claims.UserID, claims.Email) default: @@ -171,6 +197,10 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusUnauthorized, "Invalid email or password", nil) return } + if !user.EmailVerified { + respondError(w, http.StatusForbidden, "Email verification required", strPtr("Verify your email address before signing in.")) + return + } token, err := h.AuthService.GenerateToken(user.ID, user.Email) if err != nil { @@ -185,9 +215,10 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { } respondJSON(w, http.StatusOK, models.AuthTokenResponse{ - Token: token, - RefreshToken: refreshToken, - Email: user.Email, + Token: token, + RefreshToken: refreshToken, + Email: user.Email, + MustChangePassword: user.MustChangePassword, }) } @@ -220,6 +251,14 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { // Register handles POST /auth/register func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { + if !h.boolSetting(r, "registration_enabled", true) { + respondError(w, http.StatusForbidden, "Registration is disabled", nil) + return + } + if h.boolSetting(r, "sso_only_registration", false) { + respondError(w, http.StatusForbidden, "Password registration is disabled", strPtr("Use SSO to create an account.")) + return + } var req struct { Email string `json:"email"` Password string `json:"password"` @@ -253,18 +292,290 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { return } - token, err := h.AuthService.GenerateToken(userID, req.Email) - if err != nil { - respondError(w, http.StatusInternalServerError, "Failed to generate token", nil) + if err := h.sendVerificationEmail(r, userID, req.Email); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to send verification email", strPtr(err.Error())) return } - respondJSON(w, http.StatusCreated, models.AuthTokenResponse{ - Token: token, - Email: req.Email, + respondJSON(w, http.StatusCreated, map[string]interface{}{ + "email": req.Email, + "message": "Account created. Check your email to verify the account before signing in.", }) } +func (h *AuthHandler) PublicSettings(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, map[string]interface{}{ + "registration_enabled": h.boolSetting(r, "registration_enabled", true), + "sso_registration_enabled": h.boolSetting(r, "sso_registration_enabled", true), + "sso_only_registration": h.boolSetting(r, "sso_only_registration", false), + "sso_enabled": h.ssoEnabled(r), + }) +} + +func (h *AuthHandler) RequestVerificationEmail(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + } + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body", nil) + return + } + user, err := h.DB.GetUserByEmail(r.Context(), req.Email) + if err == nil && !user.EmailVerified { + _ = h.sendVerificationEmail(r, user.ID, user.Email) + } + respondJSON(w, http.StatusOK, map[string]string{"message": "If the account exists, a verification email has been sent."}) +} + +func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + respondError(w, http.StatusBadRequest, "Missing token", nil) + return + } + row, err := h.DB.ConsumeAccountToken(r.Context(), hashToken(token), "email_verification") + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid or expired verification token", nil) + return + } + if err := h.DB.MarkUserEmailVerified(r.Context(), row.UserID); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to verify email", nil) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `

Email verified. You can close this page and sign in.

`) +} + +func (h *AuthHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + } + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body", nil) + return + } + if user, err := h.DB.GetUserByEmail(r.Context(), req.Email); err == nil { + token := generateURLToken() + if err := h.DB.CreateAccountToken(r.Context(), user.ID, hashToken(token), "password_reset", time.Now().Add(time.Hour)); err == nil { + resetURL := fmt.Sprintf("%s/auth/password-reset?token=%s", h.publicBaseURL(r), url.QueryEscape(token)) + _ = h.Mailer.Send(user.Email, "Reset your Shorebird password", "Use this link to reset your password:\n\n"+resetURL+"\n\nThis link expires in 1 hour.") + } + } + respondJSON(w, http.StatusOK, map[string]string{"message": "If the account exists, a password reset email has been sent."}) +} + +func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + Password string `json:"password"` + } + if err := decodeJSON(r, &req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body", nil) + return + } + row, err := h.DB.ConsumeAccountToken(r.Context(), hashToken(req.Token), "password_reset") + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid or expired reset token", nil) + return + } + hash, err := auth.HashPassword(req.Password) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to hash password", nil) + return + } + if err := h.DB.UpdateUserPassword(r.Context(), row.UserID, hash); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to update password", nil) + return + } + respondJSON(w, http.StatusOK, map[string]string{"message": "Password updated"}) +} + +func (h *AuthHandler) PasswordResetPage(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` +Reset Password + +

Reset Password

+`, token) +} + +func (h *AuthHandler) SSOLogin(w http.ResponseWriter, r *http.Request) { + settings, _ := h.DB.ListSettings(r.Context()) + endpoint := strings.TrimRight(settings["casdoor_endpoint"], "/") + clientID := settings["casdoor_client_id"] + if endpoint == "" || clientID == "" { + respondError(w, http.StatusBadRequest, "SSO is not configured", nil) + return + } + continueURL := r.URL.Query().Get("continue") + mode := r.URL.Query().Get("mode") + values := url.Values{} + values.Set("client_id", clientID) + values.Set("response_type", "code") + values.Set("scope", "openid profile email") + values.Set("redirect_uri", h.publicBaseURL(r)+"/auth/sso/callback") + if settings["casdoor_organization"] != "" { + values.Set("organization", settings["casdoor_organization"]) + } + state := base64.RawURLEncoding.EncodeToString([]byte(url.Values{"continue": {continueURL}, "mode": {mode}}.Encode())) + values.Set("state", state) + http.Redirect(w, r, endpoint+"/login/oauth/authorize?"+values.Encode(), http.StatusFound) +} + +func (h *AuthHandler) SSOCallback(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + if code == "" { + respondError(w, http.StatusBadRequest, "Missing SSO code", nil) + return + } + stateValues := url.Values{} + if raw := r.URL.Query().Get("state"); raw != "" { + if decoded, err := base64.RawURLEncoding.DecodeString(raw); err == nil { + stateValues, _ = url.ParseQuery(string(decoded)) + } + } + profile, err := h.exchangeCasdoorProfile(r, code) + if err != nil { + respondError(w, http.StatusBadGateway, "SSO login failed", strPtr(err.Error())) + return + } + user, err := h.DB.GetUserByEmail(r.Context(), profile.Email) + if err != nil { + if !h.boolSetting(r, "sso_registration_enabled", true) { + respondError(w, http.StatusForbidden, "SSO registration is disabled", nil) + return + } + passwordHash, _ := auth.HashPassword(generateURLToken()) + userID, err := h.DB.CreateUser(r.Context(), profile.Email, profile.Name, passwordHash) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to create SSO user", strPtr(err.Error())) + return + } + _ = h.DB.MarkUserEmailVerified(r.Context(), userID) + orgID, err := h.DB.CreateOrganization(r.Context(), profile.Name+"'s Org", "team") + if err == nil { + _ = h.DB.AddUserToOrganization(r.Context(), userID, orgID, "admin") + } + user, _ = h.DB.GetUserByID(r.Context(), userID) + } + if user == nil || !user.EmailVerified { + respondError(w, http.StatusForbidden, "Email verification required", nil) + return + } + if stateValues.Get("continue") != "" { + code := globalCodeStore.set(user.Email) + http.Redirect(w, r, fmt.Sprintf("%s?code=%s", stateValues.Get("continue"), code), http.StatusFound) + return + } + token, _ := h.AuthService.GenerateToken(user.ID, user.Email) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ``, token) +} + +func (h *AuthHandler) sendVerificationEmail(r *http.Request, userID int, userEmail string) error { + token := generateURLToken() + if err := h.DB.CreateAccountToken(r.Context(), userID, hashToken(token), "email_verification", time.Now().Add(24*time.Hour)); err != nil { + return err + } + verifyURL := fmt.Sprintf("%s/auth/verify-email?token=%s", h.publicBaseURL(r), url.QueryEscape(token)) + return h.Mailer.Send(userEmail, "Verify your Shorebird account", "Use this link to verify your account:\n\n"+verifyURL+"\n\nThis link expires in 24 hours.") +} + +func (h *AuthHandler) boolSetting(r *http.Request, key string, defaultValue bool) bool { + value, err := h.DB.GetSetting(r.Context(), key) + if err != nil { + return defaultValue + } + return strings.EqualFold(value, "true") || value == "1" || strings.EqualFold(value, "yes") +} + +func (h *AuthHandler) ssoEnabled(r *http.Request) bool { + settings, err := h.DB.ListSettings(r.Context()) + if err != nil { + return false + } + return settings["casdoor_endpoint"] != "" && settings["casdoor_client_id"] != "" +} + +func (h *AuthHandler) publicBaseURL(r *http.Request) string { + if h.BaseURL != "" { + return strings.TrimRight(h.BaseURL, "/") + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return scheme + "://" + r.Host +} + +func generateURLToken() string { + b := make([]byte, 32) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func (h *AuthHandler) exchangeCasdoorProfile(r *http.Request, code string) (*casdoorProfile, error) { + settings, _ := h.DB.ListSettings(r.Context()) + endpoint := strings.TrimRight(settings["casdoor_endpoint"], "/") + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", settings["casdoor_client_id"]) + form.Set("client_secret", settings["casdoor_client_secret"]) + form.Set("code", code) + form.Set("redirect_uri", h.publicBaseURL(r)+"/auth/sso/callback") + resp, err := http.PostForm(endpoint+"/api/login/oauth/access_token", form) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("token exchange failed: %s", strings.TrimSpace(string(body))) + } + var tokenResp map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return nil, err + } + accessToken, _ := tokenResp["access_token"].(string) + if accessToken == "" { + return nil, fmt.Errorf("SSO token response missing access_token") + } + req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, endpoint+"/api/get-account", nil) + req.Header.Set("Authorization", "Bearer "+accessToken) + acctResp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer acctResp.Body.Close() + var account map[string]interface{} + if err := json.NewDecoder(acctResp.Body).Decode(&account); err != nil { + return nil, err + } + data, _ := account["data"].(map[string]interface{}) + if data == nil { + data = account + } + profile := &casdoorProfile{} + profile.Email, _ = data["email"].(string) + profile.Name, _ = data["displayName"].(string) + if profile.Name == "" { + profile.Name, _ = data["name"].(string) + } + if profile.Name == "" { + profile.Name = profile.Email + } + if profile.Email == "" { + return nil, fmt.Errorf("SSO profile missing email") + } + return profile, nil +} + func (h *AuthHandler) writeOAuthTokenResponse(w http.ResponseWriter, userID int, email string) { jwt, _ := h.AuthService.GenerateToken(userID, email) refresh, _ := auth.GenerateRefreshToken() @@ -278,12 +589,16 @@ func (h *AuthHandler) writeOAuthTokenResponse(w http.ResponseWriter, userID int, // ---- HTML login page ---- -func serveLoginHTML(w http.ResponseWriter, continueURL, errorMsg string) { +func serveLoginHTML(w http.ResponseWriter, continueURL, errorMsg string, ssoEnabled bool) { w.Header().Set("Content-Type", "text/html; charset=utf-8") errorHTML := "" if errorMsg != "" { errorHTML = fmt.Sprintf(`
%s
`, errorMsg) } + ssoHTML := "" + if ssoEnabled { + ssoHTML = fmt.Sprintf(`Sign in with Casdoor`, url.QueryEscape(continueURL)) + } fmt.Fprintf(w, ` @@ -302,6 +617,8 @@ func serveLoginHTML(w http.ResponseWriter, continueURL, errorMsg string) { .fg input:focus{border-color:#0891b2;box-shadow:0 0 0 3px rgba(8,145,178,.1)} .btn{width:100%%;padding:12px;background:#0891b2;color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer} .btn:hover{background:#0e7490} + .btn.secondary{display:block;text-align:center;text-decoration:none;margin-top:12px;background:#111827} + .btn.secondary:hover{background:#1f2937} .error{background:#fef2f2;color:#ef4444;padding:10px 14px;border-radius:8px;margin-bottom:16px;font-size:13px} @@ -315,7 +632,8 @@ func serveLoginHTML(w http.ResponseWriter, continueURL, errorMsg string) {
+ %s -`, errorHTML) +`, errorHTML, ssoHTML) } diff --git a/internal/api/handlers/releases.go b/internal/api/handlers/releases.go index ec41ce7..f798850 100644 --- a/internal/api/handlers/releases.go +++ b/internal/api/handlers/releases.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "time" "github.com/go-chi/chi/v5" @@ -27,6 +28,7 @@ func parseIntParam(r *http.Request, name string) (int, error) { type ReleaseHandler struct { DB db.Store Storage storage.Store + BaseURL string } // GetReleases handles GET /api/v1/apps/{appId}/releases @@ -130,6 +132,19 @@ func (h *ReleaseHandler) UpdateRelease(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (h *ReleaseHandler) DeleteRelease(w http.ResponseWriter, r *http.Request) { + releaseID, err := parseIntParam(r, "releaseId") + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid release ID", nil) + return + } + if err := h.DB.DeleteRelease(r.Context(), releaseID); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to delete release", strPtr(err.Error())) + return + } + w.WriteHeader(http.StatusNoContent) +} + // CreateReleaseArtifact handles POST /api/v1/apps/{appId}/releases/{releaseId}/artifacts func (h *ReleaseHandler) CreateReleaseArtifact(w http.ResponseWriter, r *http.Request) { appID := chi.URLParam(r, "appId") @@ -224,7 +239,7 @@ func (h *ReleaseHandler) GetReleaseArtifacts(w http.ResponseWriter, r *http.Requ var result []models.ReleaseArtifact for _, a := range artifacts { // Build a download URL for the artifact - downloadURL := fmt.Sprintf("http://localhost:8080/storage/dl/releases/%s", a.StorageKey) + downloadURL := fmt.Sprintf("%s/storage/dl/releases/%s", strings.TrimRight(h.BaseURL, "/"), a.StorageKey) result = append(result, models.ReleaseArtifact{ ID: a.ID, ReleaseID: a.ReleaseID, diff --git a/internal/api/handlers/router.go b/internal/api/handlers/router.go index fb042f4..ef5e3ba 100644 --- a/internal/api/handlers/router.go +++ b/internal/api/handlers/router.go @@ -8,14 +8,15 @@ import ( "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" - authpkg "github.com/shorebird-server/internal/auth" "github.com/shorebird-server/internal/api/middleware" + authpkg "github.com/shorebird-server/internal/auth" "github.com/shorebird-server/internal/db" + "github.com/shorebird-server/internal/email" "github.com/shorebird-server/internal/storage" ) // NewRouter creates the HTTP router with all API routes. -func NewRouter(authService *authpkg.Service, database db.Store, store storage.Store) *chi.Mux { +func NewRouter(authService *authpkg.Service, database db.Store, store storage.Store, mailer *email.Mailer, baseURL string) *chi.Mux { r := chi.NewRouter() r.Use(chimw.Logger) r.Use(chimw.Recoverer) @@ -31,12 +32,12 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St })) // Initialize handlers - authHandler := &AuthHandler{DB: database, AuthService: authService} - userHandler := &UserHandler{DB: database} + authHandler := &AuthHandler{DB: database, AuthService: authService, Mailer: mailer, BaseURL: baseURL} + userHandler := &UserHandler{DB: database, BaseURL: baseURL} appHandler := &AppHandler{DB: database} channelHandler := &ChannelHandler{DB: database} orgHandler := &OrganizationHandler{DB: database} - releaseHandler := &ReleaseHandler{DB: database, Storage: store} + releaseHandler := &ReleaseHandler{DB: database, Storage: store, BaseURL: baseURL} patchHandler := &PatchHandler{DB: database, Storage: store} deviceHandler := &DeviceHandler{DB: database, Storage: store} diagHandler := &DiagnosticsHandler{} @@ -44,7 +45,7 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St storageHandler := NewStorageHandler(store) // Auth middleware - authMw := middleware.AuthMiddleware(authService) + authMw := middleware.AuthMiddleware(authService, database) // --- Local storage upload/download (used when STORAGE_DRIVER=local) --- r.Route("/storage", func(r chi.Router) { @@ -56,8 +57,16 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St r.Route("/auth", func(r chi.Router) { r.Get("/login", authHandler.LoginPage) r.Post("/login", authHandler.LoginPage) + r.Get("/public-settings", authHandler.PublicSettings) r.Post("/token", authHandler.Login) r.Post("/register", authHandler.Register) + r.Post("/verify-email/request", authHandler.RequestVerificationEmail) + r.Get("/verify-email", authHandler.VerifyEmail) + r.Post("/password-reset/request", authHandler.RequestPasswordReset) + r.Get("/password-reset", authHandler.PasswordResetPage) + r.Post("/password-reset", authHandler.ResetPassword) + r.Get("/sso/login", authHandler.SSOLogin) + r.Get("/sso/callback", authHandler.SSOCallback) r.With(authMw).Post("/refresh", authHandler.Refresh) }) @@ -73,15 +82,20 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St // Users r.Get("/users/me", userHandler.GetCurrentUser) + r.Patch("/users/me/password", userHandler.UpdatePassword) r.Post("/users", userHandler.CreateUser) // Organizations r.Get("/organizations", orgHandler.GetOrganizations) + r.Post("/organizations", orgHandler.CreateOrganization) + r.Get("/organizations/{orgId}/users", orgHandler.ListOrganizationUsers) + r.Post("/organizations/{orgId}/users", orgHandler.AddOrganizationUser) // Apps r.Get("/apps", appHandler.GetApps) r.Post("/apps", appHandler.CreateApp) r.Delete("/apps/{appId}", appHandler.DeleteApp) + r.Patch("/apps/{appId}/transfer", appHandler.TransferApp) // Channels r.Get("/apps/{appId}/channels", channelHandler.GetChannels) @@ -91,6 +105,7 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St r.Get("/apps/{appId}/releases", releaseHandler.GetReleases) r.Post("/apps/{appId}/releases", releaseHandler.CreateRelease) r.Patch("/apps/{appId}/releases/{releaseId}", releaseHandler.UpdateRelease) + r.Delete("/apps/{appId}/releases/{releaseId}", releaseHandler.DeleteRelease) // Release artifacts r.Post("/apps/{appId}/releases/{releaseId}/artifacts", releaseHandler.CreateReleaseArtifact) @@ -116,6 +131,10 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St // Admin: user listing r.Get("/admin/users", adminHandler.ListUsers) + r.Post("/admin/users/{userId}/verify-email", adminHandler.VerifyUserEmail) + r.Post("/admin/users/{userId}/admin", adminHandler.SetUserAdmin) + r.Get("/admin/settings", adminHandler.GetSettings) + r.Put("/admin/settings", adminHandler.UpdateSettings) // Admin: targeted device patching r.Post("/admin/patches/{patchId}/target-devices", adminHandler.AddTargetDevice) diff --git a/internal/api/handlers/storage_handler.go b/internal/api/handlers/storage_handler.go index ce14f54..4a08c05 100644 --- a/internal/api/handlers/storage_handler.go +++ b/internal/api/handlers/storage_handler.go @@ -3,6 +3,7 @@ package handlers import ( "io" "net/http" + "strings" "github.com/go-chi/chi/v5" "github.com/shorebird-server/internal/storage" @@ -29,7 +30,24 @@ func (h *StorageHandler) Upload(w http.ResponseWriter, r *http.Request) { ct = "application/octet-stream" } - if err := h.store.UploadObject(r.Context(), key, r.Body, r.ContentLength, ct, isPublic); err != nil { + body := io.Reader(r.Body) + size := r.ContentLength + if strings.HasPrefix(ct, "multipart/form-data") { + file, header, err := r.FormFile("file") + if err != nil { + respondError(w, http.StatusBadRequest, "Upload failed: missing multipart file", nil) + return + } + defer file.Close() + body = file + size = header.Size + ct = header.Header.Get("Content-Type") + if ct == "" { + ct = "application/octet-stream" + } + } + + if err := h.store.UploadObject(r.Context(), key, body, size, ct, isPublic); err != nil { respondError(w, http.StatusInternalServerError, "Upload failed: "+err.Error(), nil) return } diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index 503a2bd..d541434 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/shorebird-server/internal/auth" + "github.com/shorebird-server/internal/db" ) type contextKey string @@ -16,7 +17,7 @@ const ( ) // AuthMiddleware validates JWT tokens and injects claims into the request context. -func AuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler { +func AuthMiddleware(authService *auth.Service, store db.Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") @@ -40,6 +41,19 @@ func AuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler { http.Error(w, `{"message":"Invalid or expired token"}`, http.StatusUnauthorized) return } + user, err := store.GetUserByID(r.Context(), claims.UserID) + if err != nil { + http.Error(w, `{"message":"User not found"}`, http.StatusUnauthorized) + return + } + if !user.EmailVerified { + http.Error(w, `{"message":"Email verification required"}`, http.StatusForbidden) + return + } + if user.MustChangePassword && r.URL.Path != "/api/v1/users/me" && r.URL.Path != "/api/v1/users/me/password" { + http.Error(w, `{"message":"Password change required"}`, http.StatusForbidden) + return + } ctx := context.WithValue(r.Context(), ClaimsKey, claims) next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/internal/config/config.go b/internal/config/config.go index 36d72a8..9e29edc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,8 @@ type Config struct { DB DBConfig Storage StorageConfig Auth AuthConfig + Email EmailConfig + Admin AdminConfig Redis RedisConfig } @@ -56,6 +58,22 @@ type AuthConfig struct { TokenDuration time.Duration } +// EmailConfig holds SMTP configuration for account emails. +type EmailConfig struct { + Host string + Port string + Username string + Password string + From string +} + +// AdminConfig holds bootstrap admin configuration. +type AdminConfig struct { + Email string + Password string + Name string +} + // RedisConfig holds Redis connection configuration. type RedisConfig struct { Addr string @@ -95,6 +113,18 @@ func Load() *Config { JWTSecret: envOrDefault("JWT_SECRET", "change-me-in-production-use-a-long-random-string"), TokenDuration: 24 * time.Hour, }, + Email: EmailConfig{ + Host: envOrDefault("SMTP_HOST", ""), + Port: envOrDefault("SMTP_PORT", "587"), + Username: envOrDefault("SMTP_USERNAME", ""), + Password: envOrDefault("SMTP_PASSWORD", ""), + From: envOrDefault("SMTP_FROM", "shorebird@localhost"), + }, + Admin: AdminConfig{ + Email: envOrDefault("DEFAULT_ADMIN_EMAIL", "admin@example.com"), + Password: envOrDefault("DEFAULT_ADMIN_PASSWORD", "admin123"), + Name: envOrDefault("DEFAULT_ADMIN_NAME", "Administrator"), + }, Redis: RedisConfig{ Addr: envOrDefault("REDIS_ADDR", "localhost:6379"), Password: envOrDefault("REDIS_PASSWORD", ""), diff --git a/internal/db/db.go b/internal/db/db.go index baa16d5..594548d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -3,6 +3,7 @@ package db import ( "context" "fmt" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -39,8 +40,6 @@ func (db *PgStore) Close() { db.pool.Close() } - - // Exec is a convenience wrapper for pool.Exec. func (db *PgStore) exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) { return db.pool.Exec(ctx, sql, args...) @@ -62,19 +61,47 @@ func (db *PgStore) query(ctx context.Context, sql string, args ...interface{}) ( func (db *PgStore) CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) { var id int err := db.queryRow(ctx, - `INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id`, + `INSERT INTO users (email, name, password_hash, is_admin) VALUES ($1, $2, $3, NOT EXISTS(SELECT 1 FROM users)) RETURNING id`, email, name, passwordHash, ).Scan(&id) return id, err } +func (db *PgStore) EnsureDefaultAdmin(ctx context.Context, email, name, passwordHash string) (bool, error) { + tx, err := db.pool.Begin(ctx) + if err != nil { + return false, err + } + defer tx.Rollback(ctx) + var id int + err = tx.QueryRow(ctx, + `INSERT INTO users (email, name, password_hash, email_verified, is_admin, must_change_password, auth_provider) + VALUES ($1, $2, $3, true, true, true, 'password') + ON CONFLICT (email) DO NOTHING + RETURNING id`, email, name, passwordHash).Scan(&id) + if err != nil { + if err == pgx.ErrNoRows { + return false, tx.Commit(ctx) + } + return false, err + } + var orgID int + if err := tx.QueryRow(ctx, `INSERT INTO organizations (name, type) VALUES ($1, 'team') RETURNING id`, name+"'s Org").Scan(&orgID); err != nil { + return false, err + } + if _, err := tx.Exec(ctx, `INSERT INTO organization_memberships (user_id, organization_id, role) VALUES ($1, $2, 'admin')`, id, orgID); err != nil { + return false, err + } + return true, tx.Commit(ctx) +} + // GetUserByEmail retrieves a user by email. func (db *PgStore) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) { row := &UserRow{} err := db.queryRow(ctx, - `SELECT id, email, name, password_hash, created_at FROM users WHERE email = $1`, + `SELECT id, email, name, password_hash, email_verified, is_admin, must_change_password, auth_provider, created_at FROM users WHERE email = $1`, email, - ).Scan(&row.ID, &row.Email, &row.Name, &row.PasswordHash, &row.CreatedAt) + ).Scan(&row.ID, &row.Email, &row.Name, &row.PasswordHash, &row.EmailVerified, &row.IsAdmin, &row.MustChangePassword, &row.AuthProvider, &row.CreatedAt) if err != nil { return nil, err } @@ -85,15 +112,35 @@ func (db *PgStore) GetUserByEmail(ctx context.Context, email string) (*UserRow, func (db *PgStore) GetUserByID(ctx context.Context, id int) (*UserRow, error) { row := &UserRow{} err := db.queryRow(ctx, - `SELECT id, email, name, password_hash, created_at FROM users WHERE id = $1`, + `SELECT id, email, name, password_hash, email_verified, is_admin, must_change_password, auth_provider, created_at FROM users WHERE id = $1`, id, - ).Scan(&row.ID, &row.Email, &row.Name, &row.PasswordHash, &row.CreatedAt) + ).Scan(&row.ID, &row.Email, &row.Name, &row.PasswordHash, &row.EmailVerified, &row.IsAdmin, &row.MustChangePassword, &row.AuthProvider, &row.CreatedAt) if err != nil { return nil, err } return row, nil } +func (db *PgStore) UpdateUserPassword(ctx context.Context, userID int, passwordHash string) error { + _, err := db.exec(ctx, `UPDATE users SET password_hash = $1, must_change_password = false WHERE id = $2`, passwordHash, userID) + return err +} + +func (db *PgStore) ClearMustChangePassword(ctx context.Context, userID int) error { + _, err := db.exec(ctx, `UPDATE users SET must_change_password = false WHERE id = $1`, userID) + return err +} + +func (db *PgStore) MarkUserEmailVerified(ctx context.Context, userID int) error { + _, err := db.exec(ctx, `UPDATE users SET email_verified = true WHERE id = $1`, userID) + return err +} + +func (db *PgStore) SetUserAdmin(ctx context.Context, userID int, isAdmin bool) error { + _, err := db.exec(ctx, `UPDATE users SET is_admin = $1 WHERE id = $2`, isAdmin, userID) + return err +} + // --- Organization queries --- // CreateOrganization creates a new organization. @@ -130,16 +177,43 @@ func (db *PgStore) GetOrganizationsByUserID(ctx context.Context, userID int) ([] return result, nil } - // AddUserToOrganization adds a user to an organization. func (db *PgStore) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error { _, err := db.exec(ctx, `INSERT INTO organization_memberships (user_id, organization_id, role) - VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, + VALUES ($1, $2, $3) ON CONFLICT (user_id, organization_id) DO UPDATE SET role = EXCLUDED.role`, userID, orgID, role) return err } +func (db *PgStore) ListOrganizationUsers(ctx context.Context, orgID int) ([]OrganizationUserRow, error) { + rows, err := db.query(ctx, + `SELECT u.id, u.email, u.name, om.role, u.email_verified, u.is_admin, u.must_change_password, u.created_at + FROM users u + JOIN organization_memberships om ON u.id = om.user_id + WHERE om.organization_id = $1 + ORDER BY u.email`, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + var result []OrganizationUserRow + for rows.Next() { + var r OrganizationUserRow + if err := rows.Scan(&r.ID, &r.Email, &r.Name, &r.Role, &r.EmailVerified, &r.IsAdmin, &r.MustChangePassword, &r.CreatedAt); err != nil { + return nil, err + } + result = append(result, r) + } + return result, nil +} + +func (db *PgStore) GetOrganizationRole(ctx context.Context, userID, orgID int) (string, error) { + var role string + err := db.queryRow(ctx, `SELECT role FROM organization_memberships WHERE user_id = $1 AND organization_id = $2`, userID, orgID).Scan(&role) + return role, err +} + // --- App queries --- // CreateApp creates a new app and returns it. @@ -205,6 +279,11 @@ func (db *PgStore) DeleteApp(ctx context.Context, appID uuid.UUID) error { return err } +func (db *PgStore) UpdateAppOrganization(ctx context.Context, appID uuid.UUID, orgID int) error { + _, err := db.exec(ctx, `UPDATE apps SET organization_id = $1, updated_at = NOW() WHERE id = $2`, orgID, appID) + return err +} + // GetAppsByUserID returns all apps the user has access to via org memberships. func (db *PgStore) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) { rows, err := db.query(ctx, @@ -229,7 +308,6 @@ func (db *PgStore) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, e return result, nil } - // --- Channel queries --- // CreateChannel creates a new channel for an app. @@ -279,7 +357,6 @@ func (db *PgStore) GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID return row, nil } - // --- Release queries --- // CreateRelease creates a new release. @@ -378,6 +455,10 @@ func (db *PgStore) GetReleasePlatformStatuses(ctx context.Context, releaseID int return result, nil } +func (db *PgStore) DeleteRelease(ctx context.Context, releaseID int) error { + _, err := db.exec(ctx, `DELETE FROM releases WHERE id = $1`, releaseID) + return err +} // --- Release artifact queries --- @@ -430,7 +511,6 @@ func (db *PgStore) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, return result, nil } - // --- Patch queries --- // GetNextPatchNumber returns the next patch number for a release. @@ -551,7 +631,6 @@ func (db *PgStore) CreatePatchArtifact(ctx context.Context, patchID int, arch, p return row, nil } - // --- Patch events --- // InsertPatchEvent records a patch lifecycle event. @@ -649,6 +728,7 @@ func (db *PgStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) { rows, err := db.query(ctx, `SELECT u.id, u.email, u.name, u.created_at, COALESCE(string_agg(DISTINCT om.role, ', '), '') as roles + , u.email_verified, u.is_admin, u.must_change_password FROM users u LEFT JOIN organization_memberships om ON u.id = om.user_id GROUP BY u.id @@ -661,7 +741,7 @@ func (db *PgStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) { var result []UserListRow for rows.Next() { var r UserListRow - if err := rows.Scan(&r.ID, &r.Email, &r.Name, &r.CreatedAt, &r.Role); err != nil { + if err := rows.Scan(&r.ID, &r.Email, &r.Name, &r.CreatedAt, &r.Role, &r.EmailVerified, &r.IsAdmin, &r.MustChangePassword); err != nil { return nil, err } result = append(result, r) @@ -669,3 +749,64 @@ func (db *PgStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) { return result, nil } +func (db *PgStore) GetSetting(ctx context.Context, key string) (string, error) { + var value string + err := db.queryRow(ctx, `SELECT value FROM settings WHERE key = $1`, key).Scan(&value) + return value, err +} + +func (db *PgStore) SetSetting(ctx context.Context, key, value string) error { + _, err := db.exec(ctx, + `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + key, value) + return err +} + +func (db *PgStore) ListSettings(ctx context.Context) (map[string]string, error) { + rows, err := db.query(ctx, `SELECT key, value FROM settings`) + if err != nil { + return nil, err + } + defer rows.Close() + result := map[string]string{} + for rows.Next() { + var key, value string + if err := rows.Scan(&key, &value); err != nil { + return nil, err + } + result[key] = value + } + return result, nil +} + +func (db *PgStore) CreateAccountToken(ctx context.Context, userID int, tokenHash, tokenType string, expiresAt time.Time) error { + _, err := db.exec(ctx, + `INSERT INTO account_tokens (user_id, token_hash, token_type, expires_at) VALUES ($1, $2, $3, $4)`, + userID, tokenHash, tokenType, expiresAt.UTC()) + return err +} + +func (db *PgStore) ConsumeAccountToken(ctx context.Context, tokenHash, tokenType string) (*AccountTokenRow, error) { + tx, err := db.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + row := &AccountTokenRow{} + err = tx.QueryRow(ctx, + `SELECT id, user_id, token_type, expires_at, used_at, created_at + FROM account_tokens + WHERE token_hash = $1 AND token_type = $2 AND used_at IS NULL AND expires_at > NOW()`, + tokenHash, tokenType).Scan(&row.ID, &row.UserID, &row.TokenType, &row.ExpiresAt, &row.UsedAt, &row.CreatedAt) + if err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, `UPDATE account_tokens SET used_at = NOW() WHERE id = $1`, row.ID); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return row, nil +} diff --git a/internal/db/migrations/001_initial_schema.sql b/internal/db/migrations/001_initial_schema.sql index a82a775..f33e8b2 100644 --- a/internal/db/migrations/001_initial_schema.sql +++ b/internal/db/migrations/001_initial_schema.sql @@ -15,6 +15,10 @@ CREATE TABLE users ( email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, password_hash TEXT NOT NULL, + email_verified BOOLEAN NOT NULL DEFAULT false, + is_admin BOOLEAN NOT NULL DEFAULT false, + must_change_password BOOLEAN NOT NULL DEFAULT false, + auth_provider TEXT NOT NULL DEFAULT 'password', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -28,6 +32,33 @@ CREATE TABLE organization_memberships ( UNIQUE(user_id, organization_id) ); +-- Server settings +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO settings (key, value) VALUES + ('registration_enabled', 'true'), + ('sso_registration_enabled', 'true'), + ('sso_only_registration', 'false'), + ('casdoor_endpoint', ''), + ('casdoor_client_id', ''), + ('casdoor_client_secret', ''), + ('casdoor_organization', 'built-in'); + +-- Email verification and password reset tokens +CREATE TABLE account_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + token_type TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + -- Apps table CREATE TABLE apps ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -162,6 +193,7 @@ CREATE INDEX idx_patch_events_client ON patch_events(app_id, client_id); CREATE INDEX idx_rolled_back_release ON rolled_back_patches(release_id); CREATE INDEX idx_patch_target_devices_patch ON patch_target_devices(patch_id); CREATE INDEX idx_patch_target_devices_client ON patch_target_devices(patch_id, client_id); +CREATE INDEX idx_account_tokens_token ON account_tokens(token_hash, token_type); -- +goose StatementEnd @@ -178,6 +210,8 @@ DROP TABLE IF EXISTS release_platform_statuses CASCADE; DROP TABLE IF EXISTS releases CASCADE; DROP TABLE IF EXISTS channels CASCADE; DROP TABLE IF EXISTS apps CASCADE; +DROP TABLE IF EXISTS account_tokens CASCADE; +DROP TABLE IF EXISTS settings CASCADE; DROP TABLE IF EXISTS organization_memberships CASCADE; DROP TABLE IF EXISTS users CASCADE; DROP TABLE IF EXISTS organizations CASCADE; diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index 40a1a64..1a167e0 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/google/uuid" @@ -45,39 +46,84 @@ func (s *SqliteStore) Close() { s.db.Close() } func (s *SqliteStore) CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) { var id int err := s.db.QueryRowContext(ctx, - `INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?) RETURNING id`, + `INSERT INTO users (email, name, password_hash, is_admin) VALUES (?, ?, ?, NOT EXISTS(SELECT 1 FROM users)) RETURNING id`, email, name, passwordHash).Scan(&id) return id, err } +func (s *SqliteStore) EnsureDefaultAdmin(ctx context.Context, email, name, passwordHash string) (bool, error) { + orgName := name + "'s Org" + res, err := s.db.ExecContext(ctx, ` + INSERT OR IGNORE INTO users (email, name, password_hash, email_verified, is_admin, must_change_password, auth_provider) + VALUES (?, ?, ?, 1, 1, 1, 'password')`, email, name, passwordHash) + if err != nil { + return false, err + } + created, _ := res.RowsAffected() + var userID int + if err := s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE email = ?`, email).Scan(&userID); err != nil { + return created > 0, err + } + if created > 0 { + var orgID int + if err := s.db.QueryRowContext(ctx, `INSERT INTO organizations (name, type) VALUES (?, 'team') RETURNING id`, orgName).Scan(&orgID); err == nil { + _ = s.AddUserToOrganization(ctx, userID, orgID, "admin") + } + } + return created > 0, nil +} func (s *SqliteStore) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) { r := &UserRow{} err := s.db.QueryRowContext(ctx, - `SELECT id, email, name, password_hash, created_at FROM users WHERE email = ?`, email). - Scan(&r.ID, &r.Email, &r.Name, &r.PasswordHash, &r.CreatedAt) - if err != nil { return nil, err } + `SELECT id, email, name, password_hash, email_verified, is_admin, must_change_password, auth_provider, created_at FROM users WHERE email = ?`, email). + Scan(&r.ID, &r.Email, &r.Name, &r.PasswordHash, &r.EmailVerified, &r.IsAdmin, &r.MustChangePassword, &r.AuthProvider, &r.CreatedAt) + if err != nil { + return nil, err + } return r, nil } func (s *SqliteStore) GetUserByID(ctx context.Context, id int) (*UserRow, error) { r := &UserRow{} err := s.db.QueryRowContext(ctx, - `SELECT id, email, name, password_hash, created_at FROM users WHERE id = ?`, id). - Scan(&r.ID, &r.Email, &r.Name, &r.PasswordHash, &r.CreatedAt) - if err != nil { return nil, err } + `SELECT id, email, name, password_hash, email_verified, is_admin, must_change_password, auth_provider, created_at FROM users WHERE id = ?`, id). + Scan(&r.ID, &r.Email, &r.Name, &r.PasswordHash, &r.EmailVerified, &r.IsAdmin, &r.MustChangePassword, &r.AuthProvider, &r.CreatedAt) + if err != nil { + return nil, err + } return r, nil } func (s *SqliteStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) { rows, err := s.db.QueryContext(ctx, - `SELECT u.id, u.email, u.name, u.created_at, COALESCE(GROUP_CONCAT(DISTINCT om.role), '') FROM users u LEFT JOIN organization_memberships om ON u.id = om.user_id GROUP BY u.id ORDER BY u.created_at DESC`) - if err != nil { return nil, err } + `SELECT u.id, u.email, u.name, u.created_at, COALESCE(GROUP_CONCAT(DISTINCT om.role), ''), u.email_verified, u.is_admin, u.must_change_password FROM users u LEFT JOIN organization_memberships om ON u.id = om.user_id GROUP BY u.id ORDER BY u.created_at DESC`) + if err != nil { + return nil, err + } defer rows.Close() var result []UserListRow for rows.Next() { var r UserListRow - if err := rows.Scan(&r.ID, &r.Email, &r.Name, &r.CreatedAt, &r.Role); err != nil { return nil, err } + if err := rows.Scan(&r.ID, &r.Email, &r.Name, &r.CreatedAt, &r.Role, &r.EmailVerified, &r.IsAdmin, &r.MustChangePassword); err != nil { + return nil, err + } result = append(result, r) } return result, nil } +func (s *SqliteStore) UpdateUserPassword(ctx context.Context, userID int, passwordHash string) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?`, passwordHash, userID) + return err +} +func (s *SqliteStore) ClearMustChangePassword(ctx context.Context, userID int) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET must_change_password = 0 WHERE id = ?`, userID) + return err +} +func (s *SqliteStore) MarkUserEmailVerified(ctx context.Context, userID int) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET email_verified = 1 WHERE id = ?`, userID) + return err +} +func (s *SqliteStore) SetUserAdmin(ctx context.Context, userID int, isAdmin bool) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET is_admin = ? WHERE id = ?`, isAdmin, userID) + return err +} // --- Organizations --- func (s *SqliteStore) CreateOrganization(ctx context.Context, name, orgType string) (int, error) { @@ -88,20 +134,95 @@ func (s *SqliteStore) CreateOrganization(ctx context.Context, name, orgType stri func (s *SqliteStore) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) { rows, err := s.db.QueryContext(ctx, `SELECT o.id, o.name, o.type, om.role FROM organizations o JOIN organization_memberships om ON o.id = om.organization_id WHERE om.user_id = ? ORDER BY o.name`, userID) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer rows.Close() var result []OrgMembershipRow for rows.Next() { var r OrgMembershipRow - if err := rows.Scan(&r.OrgID, &r.OrgName, &r.OrgType, &r.Role); err != nil { return nil, err } + if err := rows.Scan(&r.OrgID, &r.OrgName, &r.OrgType, &r.Role); err != nil { + return nil, err + } result = append(result, r) } return result, nil } func (s *SqliteStore) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error { - _, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO organization_memberships (user_id, organization_id, role) VALUES (?, ?, ?)`, userID, orgID, role) + _, err := s.db.ExecContext(ctx, `INSERT INTO organization_memberships (user_id, organization_id, role) VALUES (?, ?, ?) ON CONFLICT(user_id, organization_id) DO UPDATE SET role = excluded.role`, userID, orgID, role) return err } +func (s *SqliteStore) ListOrganizationUsers(ctx context.Context, orgID int) ([]OrganizationUserRow, error) { + rows, err := s.db.QueryContext(ctx, `SELECT u.id, u.email, u.name, om.role, u.email_verified, u.is_admin, u.must_change_password, u.created_at FROM users u JOIN organization_memberships om ON u.id = om.user_id WHERE om.organization_id = ? ORDER BY u.email`, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + var result []OrganizationUserRow + for rows.Next() { + var r OrganizationUserRow + if err := rows.Scan(&r.ID, &r.Email, &r.Name, &r.Role, &r.EmailVerified, &r.IsAdmin, &r.MustChangePassword, &r.CreatedAt); err != nil { + return nil, err + } + result = append(result, r) + } + return result, nil +} +func (s *SqliteStore) GetOrganizationRole(ctx context.Context, userID, orgID int) (string, error) { + var role string + err := s.db.QueryRowContext(ctx, `SELECT role FROM organization_memberships WHERE user_id = ? AND organization_id = ?`, userID, orgID).Scan(&role) + return role, err +} + +func (s *SqliteStore) GetSetting(ctx context.Context, key string) (string, error) { + var value string + err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&value) + return value, err +} +func (s *SqliteStore) SetSetting(ctx context.Context, key, value string) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, key, value, rfcNow()) + return err +} +func (s *SqliteStore) ListSettings(ctx context.Context) (map[string]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`) + if err != nil { + return nil, err + } + defer rows.Close() + result := map[string]string{} + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + result[k] = v + } + return result, nil +} +func (s *SqliteStore) CreateAccountToken(ctx context.Context, userID int, tokenHash, tokenType string, expiresAt time.Time) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO account_tokens (user_id, token_hash, token_type, expires_at) VALUES (?, ?, ?, ?)`, userID, tokenHash, tokenType, ShorebirdTime{Time: expiresAt.UTC()}) + return err +} +func (s *SqliteStore) ConsumeAccountToken(ctx context.Context, tokenHash, tokenType string) (*AccountTokenRow, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + r := &AccountTokenRow{} + err = tx.QueryRowContext(ctx, `SELECT id, user_id, token_type, expires_at, used_at, created_at FROM account_tokens WHERE token_hash = ? AND token_type = ? AND used_at IS NULL AND expires_at > ?`, tokenHash, tokenType, ShorebirdTime{Time: time.Now().UTC()}). + Scan(&r.ID, &r.UserID, &r.TokenType, &r.ExpiresAt, &r.UsedAt, &r.CreatedAt) + if err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, `UPDATE account_tokens SET used_at = ? WHERE id = ?`, rfcNow(), r.ID); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return r, nil +} // --- Apps --- // rfcNow returns the current UTC time in RFC3339Nano format for SQLite storage. @@ -111,14 +232,18 @@ func (s *SqliteStore) CreateApp(ctx context.Context, orgID int, displayName stri id := uuid.New().String() now := rfcNow() _, err := s.db.ExecContext(ctx, `INSERT INTO apps (id, organization_id, display_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, id, orgID, displayName, now, now) - if err != nil { return nil, err } + if err != nil { + return nil, err + } uid, _ := uuid.Parse(id) t := time.Now().UTC() return &AppRow{ID: uid, OrganizationID: orgID, DisplayName: displayName, CreatedAt: ShorebirdTime{Time: t}, UpdatedAt: ShorebirdTime{Time: t}}, nil } func (s *SqliteStore) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, organization_id, display_name, created_at, updated_at FROM apps WHERE organization_id = ? ORDER BY display_name`, orgID) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer rows.Close() return scanApps(rows) } @@ -127,22 +252,32 @@ func (s *SqliteStore) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, var idStr string err := s.db.QueryRowContext(ctx, `SELECT id, organization_id, display_name, created_at, updated_at FROM apps WHERE id = ?`, appID.String()). Scan(&idStr, &r.OrganizationID, &r.DisplayName, &r.CreatedAt, &r.UpdatedAt) - if err != nil { return nil, err } + if err != nil { + return nil, err + } r.ID, _ = uuid.Parse(idStr) return r, nil } func (s *SqliteStore) GetAppByIDString(ctx context.Context, appID string) (*AppRow, error) { id, err := uuid.Parse(appID) - if err != nil { return nil, fmt.Errorf("invalid app_id: %w", err) } + if err != nil { + return nil, fmt.Errorf("invalid app_id: %w", err) + } return s.GetAppByID(ctx, id) } func (s *SqliteStore) DeleteApp(ctx context.Context, appID uuid.UUID) error { _, err := s.db.ExecContext(ctx, `DELETE FROM apps WHERE id = ?`, appID.String()) return err } +func (s *SqliteStore) UpdateAppOrganization(ctx context.Context, appID uuid.UUID, orgID int) error { + _, err := s.db.ExecContext(ctx, `UPDATE apps SET organization_id = ?, updated_at = ? WHERE id = ?`, orgID, rfcNow(), appID.String()) + return err +} func (s *SqliteStore) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) { rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT a.id, a.organization_id, a.display_name, a.created_at, a.updated_at FROM apps a JOIN organization_memberships om ON a.organization_id = om.organization_id WHERE om.user_id = ? ORDER BY a.display_name`, userID) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer rows.Close() return scanApps(rows) } @@ -151,7 +286,9 @@ func scanApps(rows *sql.Rows) ([]AppRow, error) { for rows.Next() { var r AppRow var idStr string - if err := rows.Scan(&idStr, &r.OrganizationID, &r.DisplayName, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, err } + if err := rows.Scan(&idStr, &r.OrganizationID, &r.DisplayName, &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } r.ID, _ = uuid.Parse(idStr) result = append(result, r) } @@ -162,18 +299,26 @@ func scanApps(rows *sql.Rows) ([]AppRow, error) { func (s *SqliteStore) CreateChannel(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) { r := &ChannelRow{AppID: appID, Name: name} err := s.db.QueryRowContext(ctx, `INSERT INTO channels (app_id, name) VALUES (?, ?) RETURNING id, created_at`, appID.String(), name).Scan(&r.ID, &r.CreatedAt) - if err != nil { return nil, err } + if err != nil { + return nil, err + } return r, nil } func (s *SqliteStore) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, app_id, name, created_at FROM channels WHERE app_id = ? ORDER BY name`, appID.String()) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer rows.Close() var result []ChannelRow for rows.Next() { - var r ChannelRow; var aid string - if err := rows.Scan(&r.ID, &aid, &r.Name, &r.CreatedAt); err != nil { return nil, err } - r.AppID, _ = uuid.Parse(aid); result = append(result, r) + var r ChannelRow + var aid string + if err := rows.Scan(&r.ID, &aid, &r.Name, &r.CreatedAt); err != nil { + return nil, err + } + r.AppID, _ = uuid.Parse(aid) + result = append(result, r) } return result, nil } @@ -181,7 +326,9 @@ func (s *SqliteStore) GetChannelByAppIDAndName(ctx context.Context, appID uuid.U r := &ChannelRow{} var aid string err := s.db.QueryRowContext(ctx, `SELECT id, app_id, name, created_at FROM channels WHERE app_id = ? AND name = ?`, appID.String(), name).Scan(&r.ID, &aid, &r.Name, &r.CreatedAt) - if err != nil { return nil, err } + if err != nil { + return nil, err + } r.AppID, _ = uuid.Parse(aid) return r, nil } @@ -189,16 +336,26 @@ func (s *SqliteStore) GetChannelByAppIDAndName(ctx context.Context, appID uuid.U // --- Releases --- func (s *SqliteStore) CreateRelease(ctx context.Context, appID uuid.UUID, version, flutterRevision string, flutterVersion, displayName *string) (*ReleaseRow, error) { r := &ReleaseRow{AppID: appID, Version: version, FlutterRevision: flutterRevision, FlutterVersion: flutterVersion, DisplayName: displayName} - var fv, dn interface{}; if flutterVersion != nil { fv = *flutterVersion }; if displayName != nil { dn = *displayName } + var fv, dn interface{} + if flutterVersion != nil { + fv = *flutterVersion + } + if displayName != nil { + dn = *displayName + } err := s.db.QueryRowContext(ctx, `INSERT INTO releases (app_id, version, flutter_revision, flutter_version, display_name) VALUES (?, ?, ?, ?, ?) RETURNING id, notes, created_at, updated_at`, appID.String(), version, flutterRevision, fv, dn).Scan(&r.ID, &r.Notes, &r.CreatedAt, &r.UpdatedAt) - if err != nil { return nil, err } + if err != nil { + return nil, err + } return r, nil } func (s *SqliteStore) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error) { r := &ReleaseRow{} var aid string err := s.db.QueryRowContext(ctx, `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at FROM releases WHERE id = ?`, releaseID).Scan(&r.ID, &aid, &r.Version, &r.FlutterRevision, &r.FlutterVersion, &r.DisplayName, &r.Notes, &r.CreatedAt, &r.UpdatedAt) - if err != nil { return nil, err } + if err != nil { + return nil, err + } r.AppID, _ = uuid.Parse(aid) return r, nil } @@ -206,58 +363,93 @@ func (s *SqliteStore) GetReleaseByAppIDAndVersion(ctx context.Context, appID uui r := &ReleaseRow{} var aid string err := s.db.QueryRowContext(ctx, `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at FROM releases WHERE app_id = ? AND version = ?`, appID.String(), version).Scan(&r.ID, &aid, &r.Version, &r.FlutterRevision, &r.FlutterVersion, &r.DisplayName, &r.Notes, &r.CreatedAt, &r.UpdatedAt) - if err != nil { return nil, err } + if err != nil { + return nil, err + } r.AppID, _ = uuid.Parse(aid) return r, nil } func (s *SqliteStore) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error) { _ = sideloadableOnly rows, err := s.db.QueryContext(ctx, `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at FROM releases WHERE app_id = ? ORDER BY created_at DESC`, appID.String()) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer rows.Close() var result []ReleaseRow for rows.Next() { - var r ReleaseRow; var aid string - if err := rows.Scan(&r.ID, &aid, &r.Version, &r.FlutterRevision, &r.FlutterVersion, &r.DisplayName, &r.Notes, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, err } - r.AppID, _ = uuid.Parse(aid); result = append(result, r) + var r ReleaseRow + var aid string + if err := rows.Scan(&r.ID, &aid, &r.Version, &r.FlutterRevision, &r.FlutterVersion, &r.DisplayName, &r.Notes, &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + r.AppID, _ = uuid.Parse(aid) + result = append(result, r) } return result, nil } func (s *SqliteStore) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error { metaJSON := "{}" - if metadata != nil { b, _ := json.Marshal(metadata); metaJSON = string(b) } + if metadata != nil { + b, _ := json.Marshal(metadata) + metaJSON = string(b) + } _, err := s.db.ExecContext(ctx, `INSERT INTO release_platform_statuses (release_id, platform, status, metadata, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(release_id, platform) DO UPDATE SET status=excluded.status, metadata=excluded.metadata, updated_at=excluded.updated_at`, releaseID, platform, status, metaJSON, rfcNow()) return err } func (s *SqliteStore) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT platform, status FROM release_platform_statuses WHERE release_id = ?`, releaseID) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer rows.Close() result := make(map[string]string) - for rows.Next() { var p, st string; if err := rows.Scan(&p, &st); err != nil { return nil, err }; result[p] = st } + for rows.Next() { + var p, st string + if err := rows.Scan(&p, &st); err != nil { + return nil, err + } + result[p] = st + } return result, nil } +func (s *SqliteStore) DeleteRelease(ctx context.Context, releaseID int) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM releases WHERE id = ?`, releaseID) + return err +} // --- Release Artifacts --- func (s *SqliteStore) CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error) { r := &ArtifactRow{ReleaseID: releaseID, Arch: arch, Platform: platform, Hash: hash, Size: size, StorageKey: storageKey, CanSideload: canSideload, PodfileLockHash: podfileLockHash} err := s.db.QueryRowContext(ctx, `INSERT INTO release_artifacts (release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at`, releaseID, arch, platform, hash, size, storageKey, canSideload, podfileLockHash).Scan(&r.ID, &r.CreatedAt) - if err != nil { return nil, err } + if err != nil { + return nil, err + } return r, nil } func (s *SqliteStore) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, platform *string) ([]ArtifactRow, error) { query := `SELECT id, release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash, created_at FROM release_artifacts WHERE release_id = ?` args := []interface{}{releaseID} - if arch != nil { query += " AND arch = ?"; args = append(args, *arch) } - if platform != nil { query += " AND platform = ?"; args = append(args, *platform) } + if arch != nil { + query += " AND arch = ?" + args = append(args, *arch) + } + if platform != nil { + query += " AND platform = ?" + args = append(args, *platform) + } query += " ORDER BY arch" rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer rows.Close() var result []ArtifactRow for rows.Next() { var r ArtifactRow - if err := rows.Scan(&r.ID, &r.ReleaseID, &r.Arch, &r.Platform, &r.Hash, &r.Size, &r.StorageKey, &r.CanSideload, &r.PodfileLockHash, &r.CreatedAt); err != nil { return nil, err } + if err := rows.Scan(&r.ID, &r.ReleaseID, &r.Arch, &r.Platform, &r.Hash, &r.Size, &r.StorageKey, &r.CanSideload, &r.PodfileLockHash, &r.CreatedAt); err != nil { + return nil, err + } result = append(result, r) } return result, nil @@ -272,29 +464,39 @@ func (s *SqliteStore) GetNextPatchNumber(ctx context.Context, releaseID int) (in func (s *SqliteStore) CreatePatch(ctx context.Context, releaseID int, number int, notes *string) (*PatchRow, error) { r := &PatchRow{ReleaseID: releaseID, Number: number, Notes: notes} err := s.db.QueryRowContext(ctx, `INSERT INTO patches (release_id, number, notes) VALUES (?, ?, ?) RETURNING id, created_at`, releaseID, number, notes).Scan(&r.ID, &r.CreatedAt) - if err != nil { return nil, err } + if err != nil { + return nil, err + } return r, nil } func (s *SqliteStore) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) { r := &PatchRow{} err := s.db.QueryRowContext(ctx, `SELECT id, release_id, number, notes, created_at FROM patches WHERE id = ?`, patchID).Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt) - if err != nil { return nil, err } + if err != nil { + return nil, err + } return r, nil } func (s *SqliteStore) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error) { r := &PatchRow{} err := s.db.QueryRowContext(ctx, `SELECT id, release_id, number, notes, created_at FROM patches WHERE release_id = ? ORDER BY number DESC LIMIT 1`, releaseID).Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt) - if err != nil { return nil, err } + if err != nil { + return nil, err + } return r, nil } func (s *SqliteStore) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) { rows, err := s.db.QueryContext(ctx, `SELECT p.id, p.release_id, p.number, p.notes, p.created_at, COALESCE(pc.channel_id, 0), COALESCE(pc.promoted_at, p.created_at) FROM patches p LEFT JOIN patch_channels pc ON p.id = pc.patch_id WHERE p.release_id = ? ORDER BY p.number DESC`, releaseID) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer rows.Close() var result []PatchWithChannelRow for rows.Next() { var r PatchWithChannelRow - if err := rows.Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt, &r.ChannelID, &r.PromotedAt); err != nil { return nil, err } + if err := rows.Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt, &r.ChannelID, &r.PromotedAt); err != nil { + return nil, err + } result = append(result, r) } return result, nil @@ -306,7 +508,9 @@ func (s *SqliteStore) PromotePatch(ctx context.Context, patchID, channelID int) func (s *SqliteStore) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int) (*PatchWithArtifactRow, error) { r := &PatchWithArtifactRow{} err := s.db.QueryRowContext(ctx, `SELECT p.id, p.release_id, p.number, p.notes, p.created_at, pa.hash, pa.storage_key, pa.hash_signature FROM patches p JOIN patch_channels pc ON p.id = pc.patch_id LEFT JOIN patch_artifacts pa ON p.id = pa.patch_id WHERE p.release_id = ? AND pc.channel_id = ? ORDER BY p.number DESC LIMIT 1`, releaseID, channelID).Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt, &r.Hash, &r.StorageKey, &r.HashSignature) - if err != nil { return nil, err } + if err != nil { + return nil, err + } return r, nil } @@ -314,7 +518,9 @@ func (s *SqliteStore) GetLatestPromotedPatch(ctx context.Context, releaseID, cha func (s *SqliteStore) CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error) { r := &PatchArtifactRow{PatchID: patchID, Arch: arch, Platform: platform, Hash: hash, Size: size, StorageKey: storageKey, HashSignature: hashSignature, PodfileLockHash: podfileLockHash} err := s.db.QueryRowContext(ctx, `INSERT INTO patch_artifacts (patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at`, patchID, arch, platform, hash, size, storageKey, hashSignature, podfileLockHash).Scan(&r.ID, &r.CreatedAt) - if err != nil { return nil, err } + if err != nil { + return nil, err + } return r, nil } @@ -331,10 +537,18 @@ func (s *SqliteStore) RollbackPatch(ctx context.Context, releaseID, patchNumber } func (s *SqliteStore) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]int, error) { rows, err := s.db.QueryContext(ctx, `SELECT patch_number FROM rolled_back_patches WHERE release_id = ?`, releaseID) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer rows.Close() var result []int - for rows.Next() { var n int; if err := rows.Scan(&n); err != nil { return nil, err }; result = append(result, n) } + for rows.Next() { + var n int + if err := rows.Scan(&n); err != nil { + return nil, err + } + result = append(result, n) + } return result, nil } @@ -350,10 +564,18 @@ func (s *SqliteStore) IsPatchTargetedToDevice(ctx context.Context, patchID int, } func (s *SqliteStore) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT client_id FROM patch_target_devices WHERE patch_id = ?`, patchID) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer rows.Close() var result []string - for rows.Next() { var cid string; if err := rows.Scan(&cid); err != nil { return nil, err }; result = append(result, cid) } + for rows.Next() { + var cid string + if err := rows.Scan(&cid); err != nil { + return nil, err + } + result = append(result, cid) + } return result, nil } func (s *SqliteStore) HasTargetDevices(ctx context.Context, patchID int) (bool, error) { @@ -366,8 +588,10 @@ func (s *SqliteStore) HasTargetDevices(ctx context.Context, patchID int) (bool, func (s *SqliteStore) migrate(ctx context.Context) error { _, err := s.db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS organizations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'team', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); - CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); + CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, password_hash TEXT NOT NULL, email_verified INTEGER NOT NULL DEFAULT 0, is_admin INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0, auth_provider TEXT NOT NULL DEFAULT 'password', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); CREATE TABLE IF NOT EXISTS organization_memberships (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(user_id, organization_id)); + CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); + CREATE TABLE IF NOT EXISTS account_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, token_type TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); CREATE TABLE IF NOT EXISTS apps (id TEXT PRIMARY KEY, organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, display_name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); CREATE TABLE IF NOT EXISTS channels (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(app_id, name)); CREATE TABLE IF NOT EXISTS releases (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, version TEXT NOT NULL, flutter_revision TEXT NOT NULL, flutter_version TEXT, display_name TEXT, notes TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); @@ -389,6 +613,33 @@ func (s *SqliteStore) migrate(ctx context.Context) error { CREATE INDEX IF NOT EXISTS idx_rolled_back_release ON rolled_back_patches(release_id); CREATE INDEX IF NOT EXISTS idx_patch_target_devices_patch ON patch_target_devices(patch_id); CREATE INDEX IF NOT EXISTS idx_patch_target_devices_client ON patch_target_devices(patch_id, client_id); + CREATE INDEX IF NOT EXISTS idx_account_tokens_token ON account_tokens(token_hash, token_type); + `) + if err != nil { + return err + } + alter := []string{ + `ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 1`, + `ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'password'`, + } + for _, stmt := range alter { + if _, err := s.db.ExecContext(ctx, stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { + return err + } + } + _, err = s.db.ExecContext(ctx, ` + UPDATE users SET email_verified = 1 WHERE email_verified IS NULL; + UPDATE users SET is_admin = 1 WHERE id = (SELECT MIN(id) FROM users) AND NOT EXISTS (SELECT 1 FROM users WHERE is_admin = 1); + INSERT OR IGNORE INTO settings (key, value) VALUES + ('registration_enabled', 'true'), + ('sso_registration_enabled', 'true'), + ('sso_only_registration', 'false'), + ('casdoor_endpoint', ''), + ('casdoor_client_id', ''), + ('casdoor_client_secret', ''), + ('casdoor_organization', 'built-in'); `) return err -} \ No newline at end of file +} diff --git a/internal/db/store.go b/internal/db/store.go index a0d4914..49dce2e 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -75,14 +75,28 @@ type Store interface { // --- Users --- CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) + EnsureDefaultAdmin(ctx context.Context, email, name, passwordHash string) (bool, error) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) GetUserByID(ctx context.Context, id int) (*UserRow, error) ListAllUsers(ctx context.Context) ([]UserListRow, error) + UpdateUserPassword(ctx context.Context, userID int, passwordHash string) error + ClearMustChangePassword(ctx context.Context, userID int) error + MarkUserEmailVerified(ctx context.Context, userID int) error + SetUserAdmin(ctx context.Context, userID int, isAdmin bool) error // --- Organizations --- CreateOrganization(ctx context.Context, name, orgType string) (int, error) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error + ListOrganizationUsers(ctx context.Context, orgID int) ([]OrganizationUserRow, error) + GetOrganizationRole(ctx context.Context, userID, orgID int) (string, error) + + // --- Settings and account tokens --- + GetSetting(ctx context.Context, key string) (string, error) + SetSetting(ctx context.Context, key, value string) error + ListSettings(ctx context.Context) (map[string]string, error) + CreateAccountToken(ctx context.Context, userID int, tokenHash, tokenType string, expiresAt time.Time) error + ConsumeAccountToken(ctx context.Context, tokenHash, tokenType string) (*AccountTokenRow, error) // --- Apps --- CreateApp(ctx context.Context, orgID int, displayName string) (*AppRow, error) @@ -90,6 +104,7 @@ type Store interface { GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error) GetAppByIDString(ctx context.Context, appID string) (*AppRow, error) DeleteApp(ctx context.Context, appID uuid.UUID) error + UpdateAppOrganization(ctx context.Context, appID uuid.UUID, orgID int) error GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) // --- Channels --- @@ -104,6 +119,7 @@ type Store interface { GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) + DeleteRelease(ctx context.Context, releaseID int) error // --- Release Artifacts --- CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error) @@ -139,11 +155,15 @@ type Store interface { // UserRow represents a user row from the database. type UserRow struct { - ID int - Email string - Name string - PasswordHash string - CreatedAt ShorebirdTime + ID int + Email string + Name string + PasswordHash string + EmailVerified bool + IsAdmin bool + MustChangePassword bool + AuthProvider string + CreatedAt ShorebirdTime } // OrgMembershipRow represents an organization membership query result. @@ -154,6 +174,18 @@ type OrgMembershipRow struct { Role string } +// OrganizationUserRow is used for organization member listing. +type OrganizationUserRow struct { + ID int + Email string + Name string + Role string + EmailVerified bool + IsAdmin bool + MustChangePassword bool + CreatedAt ShorebirdTime +} + // AppRow represents an app row. type AppRow struct { ID uuid.UUID @@ -246,9 +278,22 @@ type PatchArtifactRow struct { // UserListRow is used by the admin user listing endpoint. type UserListRow struct { - ID int - Email string - Name string - CreatedAt ShorebirdTime - Role string + ID int + Email string + Name string + CreatedAt ShorebirdTime + Role string + EmailVerified bool + IsAdmin bool + MustChangePassword bool +} + +// AccountTokenRow represents a verification or password reset token. +type AccountTokenRow struct { + ID int + UserID int + TokenType string + ExpiresAt ShorebirdTime + UsedAt NullShorebirdTime + CreatedAt ShorebirdTime } diff --git a/internal/email/email.go b/internal/email/email.go new file mode 100644 index 0000000..09891ff --- /dev/null +++ b/internal/email/email.go @@ -0,0 +1,59 @@ +package email + +import ( + "fmt" + "log" + "net/smtp" + "strings" +) + +// Config contains SMTP settings for outbound account emails. +type Config struct { + Host string + Port string + Username string + Password string + From string +} + +// Mailer sends account emails. When SMTP is not configured, it logs the +// message body so local self-hosted installs still expose verification links. +type Mailer struct { + cfg Config +} + +// NewMailer creates a Mailer. +func NewMailer(cfg Config) *Mailer { + if cfg.Port == "" { + cfg.Port = "587" + } + if cfg.From == "" { + cfg.From = "shorebird@localhost" + } + return &Mailer{cfg: cfg} +} + +// Send sends a plain-text email. +func (m *Mailer) Send(to, subject, body string) error { + if m == nil || m.cfg.Host == "" { + log.Printf("email disabled; would send to %s subject %q:\n%s", to, subject, body) + return nil + } + + msg := strings.Join([]string{ + "From: " + m.cfg.From, + "To: " + to, + "Subject: " + subject, + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "", + body, + }, "\r\n") + + addr := fmt.Sprintf("%s:%s", m.cfg.Host, m.cfg.Port) + var auth smtp.Auth + if m.cfg.Username != "" { + auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host) + } + return smtp.SendMail(addr, auth, m.cfg.From, []string{to}, []byte(msg)) +} diff --git a/internal/models/models.go b/internal/models/models.go index 5b29462..cf8fe84 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -17,16 +17,17 @@ type App struct { // AppMetadata is the full app info returned by GET /apps. type AppMetadata struct { - AppID string `json:"app_id"` - DisplayName string `json:"display_name"` - LatestReleaseVersion *string `json:"latest_release_version"` - LatestPatchNumber *int `json:"latest_patch_number"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Platforms []string `json:"platforms"` - LatestReleases map[string]LatestRelease `json:"latest_releases"` - PendingReleases map[string]PendingRelease `json:"pending_releases"` - IconURL *string `json:"icon_url"` + AppID string `json:"app_id"` + OrganizationID int `json:"organization_id,omitempty"` + DisplayName string `json:"display_name"` + LatestReleaseVersion *string `json:"latest_release_version"` + LatestPatchNumber *int `json:"latest_patch_number"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Platforms []string `json:"platforms"` + LatestReleases map[string]LatestRelease `json:"latest_releases"` + PendingReleases map[string]PendingRelease `json:"pending_releases"` + IconURL *string `json:"icon_url"` } // LatestRelease represents the latest analyzed release per platform. @@ -51,16 +52,16 @@ type Channel struct { // Release represents a release of an application. type Release struct { - ID int `json:"id"` - AppID uuid.UUID `json:"app_id"` - Version string `json:"version"` - FlutterRevision string `json:"flutter_revision"` - FlutterVersion *string `json:"flutter_version"` - DisplayName *string `json:"display_name"` - Notes *string `json:"notes"` - PlatformStatuses map[string]string `json:"platform_statuses"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int `json:"id"` + AppID uuid.UUID `json:"app_id"` + Version string `json:"version"` + FlutterRevision string `json:"flutter_revision"` + FlutterVersion *string `json:"flutter_version"` + DisplayName *string `json:"display_name"` + Notes *string `json:"notes"` + PlatformStatuses map[string]string `json:"platform_statuses"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ReleaseStatus values. @@ -72,11 +73,11 @@ const ( // ReleasePlatform values. const ( - PlatformAndroid = "android" - PlatformIOS = "ios" - PlatformMacOS = "macos" - PlatformWindows = "windows" - PlatformLinux = "linux" + PlatformAndroid = "android" + PlatformIOS = "ios" + PlatformMacOS = "macos" + PlatformWindows = "windows" + PlatformLinux = "linux" ) // ReleaseArtifact represents metadata about a release artifact. @@ -95,18 +96,18 @@ type ReleaseArtifact struct { // Patch represents a patch (hotfix update) for a release. type Patch struct { - ID int `json:"id"` - Number int `json:"number"` - Notes *string `json:"notes"` + ID int `json:"id"` + Number int `json:"number"` + Notes *string `json:"notes"` } // ReleasePatch is the join between releases and patches. type ReleasePatch struct { - ID int `json:"id"` - ReleaseID int `json:"release_id"` - PatchID int `json:"patch_id"` - PatchNumber int `json:"patch_number"` - CreatedAt time.Time `json:"created_at"` + ID int `json:"id"` + ReleaseID int `json:"release_id"` + PatchID int `json:"patch_id"` + PatchNumber int `json:"patch_number"` + CreatedAt time.Time `json:"created_at"` } // PatchArtifact represents metadata about a patch artifact. @@ -122,22 +123,22 @@ type PatchArtifact struct { // PatchCheckRequest is the POST /patches/check request body. type PatchCheckRequest struct { - ReleaseVersion string `json:"release_version"` - PatchNumber *int `json:"patch_number"` + ReleaseVersion string `json:"release_version"` + PatchNumber *int `json:"patch_number"` PatchHash *string `json:"patch_hash"` - Platform string `json:"platform"` - Arch string `json:"arch"` - AppID string `json:"app_id"` - Channel string `json:"channel"` + Platform string `json:"platform"` + Arch string `json:"arch"` + AppID string `json:"app_id"` + Channel string `json:"channel"` ClientID *string `json:"client_id"` CurrentPatchNumber *int `json:"current_patch_number"` } // PatchCheckResponse is the POST /patches/check response body. type PatchCheckResponse struct { - PatchAvailable bool `json:"patch_available"` - Patch *PatchCheckMetadata `json:"patch"` - RolledBackPatchNumbers []int `json:"rolled_back_patch_numbers"` + PatchAvailable bool `json:"patch_available"` + Patch *PatchCheckMetadata `json:"patch"` + RolledBackPatchNumbers []int `json:"rolled_back_patch_numbers"` } // PatchCheckMetadata is the patch metadata returned in patch check. @@ -283,9 +284,9 @@ type CreatePatchArtifactResponse struct { // UpdateReleaseRequest is the PATCH /releases/{id} request body. type UpdateReleaseRequest struct { - Status string `json:"status"` - Platform string `json:"platform"` - Metadata map[string]interface{} `json:"metadata"` + Status string `json:"status"` + Platform string `json:"platform"` + Metadata map[string]interface{} `json:"metadata"` } // PromotePatchRequest is the POST /patches/promote request body. @@ -318,7 +319,8 @@ type AuthTokenRequest struct { // AuthTokenResponse is the login response. type AuthTokenResponse struct { - Token string `json:"token"` - RefreshToken string `json:"refresh_token"` - Email string `json:"email"` + Token string `json:"token"` + RefreshToken string `json:"refresh_token"` + Email string `json:"email"` + MustChangePassword bool `json:"must_change_password,omitempty"` } diff --git a/web/index.html b/web/index.html index e8f4846..372e156 100644 --- a/web/index.html +++ b/web/index.html @@ -31,8 +31,10 @@ Sign In +

- No account? Register + Reset password + · No account? Register

@@ -83,6 +85,9 @@ 👥 Users + + 🏢 Organizations + ⚙️ Settings @@ -148,16 +153,105 @@ +
+
+
+

Organizations

+ +
+
+
+
+

Organization Members

+
+
+
+ +
+
+
+

App

+ +
+
+
+ + + + + +
+
+ + + + +
+
+
+ +
+
+
+

Release

+ +
+
+
+ + + + +
+
+ + + +
+
+
+
+
+

Personal Settings

+
+
+
+ + +
+
+ + +
+ +
+
+

Server Configuration

+
+

Admin Settings

+
+
+
+
+
+
+
+
+
+ +
+
+

API Token

@@ -190,6 +284,10 @@
+
+ + +
+ + + + + + + + diff --git a/web/js/app.js b/web/js/app.js index cdaabd0..4a0e7be 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -1,5 +1,5 @@ -/** - * Shorebird Self-Hosted Dashboard — Complete SPA Application +/** + * Shorebird Self-Hosted Dashboard - Complete SPA Application * Vanilla JS, no framework dependencies. */ @@ -9,6 +9,9 @@ const state = { user: null, apps: [], users: [], + organizations: [], + currentApp: null, + currentRelease: null, confirmCallback: null, }; @@ -53,12 +56,13 @@ function navigate(page) { const link = document.querySelector(`#sidebarNav a[data-page="${page}"]`); if (link) link.classList.add('active'); - const titles = { dashboard: 'Dashboard', apps: 'Apps', users: 'Users', settings: 'Settings' }; + const titles = { dashboard: 'Dashboard', apps: 'Apps', users: 'Users', organizations: 'Organizations', settings: 'Settings' }; document.getElementById('pageTitle').textContent = titles[page] || page; if (page === 'dashboard') loadDashboard(); if (page === 'apps') loadApps(); if (page === 'users') loadUsers(); + if (page === 'organizations') loadOrganizations(); if (page === 'settings') loadSettings(); } @@ -91,10 +95,27 @@ function executeConfirm() { async function login(email, password) { const data = await api.request('POST', '/auth/token', { email, password }); state.token = data.token; + state.mustChangePassword = data.must_change_password; localStorage.setItem('shorebird_token', data.token); await loadCurrentUser(); showApp(); - navigate('dashboard'); + if (state.mustChangePassword || state.user.must_change_password) { + navigate('settings'); + openModal('forcePasswordOverlay'); + } else { + navigate('dashboard'); + } +} + +async function loadPublicSettings() { + try { + const data = await api.request('GET', '/auth/public-settings'); + document.getElementById('ssoLoginBtn').style.display = data.sso_enabled ? 'inline-flex' : 'none'; + const canRegister = data.registration_enabled && !data.sso_only_registration; + document.getElementById('registerPrompt').style.display = canRegister ? 'inline' : 'none'; + } catch { + document.getElementById('ssoLoginBtn').style.display = 'none'; + } } function logout() { @@ -109,9 +130,9 @@ function logout() { async function loadCurrentUser() { try { state.user = await api.get('/users/me'); - document.getElementById('currentUserEmail').textContent = state.user.email || '—'; + document.getElementById('currentUserEmail').textContent = state.user.email || '-'; + if (state.user.must_change_password) setTimeout(() => openModal('forcePasswordOverlay'), 0); } catch (e) { - // User endpoint might not be available state.user = { email: 'admin' }; } } @@ -122,14 +143,9 @@ function showApp() { } async function register(name, email, password) { - const data = await api.request('POST', '/auth/register', { name, email, password }); - state.token = data.token; - localStorage.setItem('shorebird_token', data.token); - await loadCurrentUser(); + await api.request('POST', '/auth/register', { name, email, password }); closeModal('registerOverlay'); - showApp(); - navigate('dashboard'); - showToast('Account created successfully', 'success'); + showToast('Account created. Check your email to verify before signing in.', 'success'); } // ---- Dashboard ---- @@ -138,7 +154,7 @@ async function loadDashboard() { const appsResp = await api.get('/apps'); state.apps = appsResp.apps || []; document.getElementById('statApps').textContent = state.apps.length; - document.getElementById('statPatches').textContent = '—'; + document.getElementById('statPatches').textContent = '-'; // Try to get users try { @@ -146,7 +162,7 @@ async function loadDashboard() { state.users = usersResp.users || []; document.getElementById('statUsers').textContent = state.users.length; } catch { - document.getElementById('statUsers').textContent = '—'; + document.getElementById('statUsers').textContent = '-'; } // Try to get organizations @@ -155,13 +171,13 @@ async function loadDashboard() { const orgs = orgsResp.organizations || []; document.getElementById('statOrganizations').textContent = orgs.length; } catch { - document.getElementById('statOrganizations').textContent = '—'; + document.getElementById('statOrganizations').textContent = '-'; } // Recent apps table const tableDiv = document.getElementById('recentAppsTable'); if (state.apps.length === 0) { - tableDiv.innerHTML = '
📱

No apps yet. Create your first app to get started.

'; + tableDiv.innerHTML = '
Apps

No apps yet. Create your first app to get started.

'; } else { const recent = state.apps.slice(0, 5); tableDiv.innerHTML = renderTable( @@ -186,7 +202,7 @@ async function loadApps() { const tableDiv = document.getElementById('appsTable'); if (state.apps.length === 0) { - tableDiv.innerHTML = '
📱

No apps yet. Create your first app.

'; + tableDiv.innerHTML = '
Apps

No apps yet. Create your first app.

'; return; } @@ -196,8 +212,8 @@ async function loadApps() { a.display_name, `${a.app_id}`, new Date(a.created_at).toLocaleDateString(), - ` - `, + ` + `, ]) ); } catch (e) { @@ -205,9 +221,9 @@ async function loadApps() { } } -async function createApp(displayName) { +async function createApp(displayName, organizationId) { try { - await api.post('/apps', { display_name: displayName, organization_id: 0 }); + await api.post('/apps', { display_name: displayName, organization_id: Number(organizationId || 0) }); closeModal('createAppOverlay'); showToast(`App "${displayName}" created`, 'success'); loadApps(); @@ -232,6 +248,16 @@ function confirmDeleteApp(appId, name) { function openCreateAppModal() { document.getElementById('appDisplayName').value = ''; + const select = document.getElementById('appOrganizationId'); + select.innerHTML = ''; + api.get('/organizations').then(resp => { + state.organizations = resp.organizations || []; + if (state.organizations.length > 0) { + select.innerHTML = state.organizations + .map(m => ``) + .join(''); + } + }).catch(() => {}); openModal('createAppOverlay'); } @@ -243,7 +269,7 @@ async function loadUsers() { const tableDiv = document.getElementById('usersTable'); if (state.users.length === 0) { - tableDiv.innerHTML = '
👥

No users found.

'; + tableDiv.innerHTML = '
Users

No users found.

'; return; } @@ -252,15 +278,15 @@ async function loadUsers() { state.users.map(u => [ u.id, u.email, - u.name || '—', + u.name || '-', u.role ? `${u.role}` : `member`, - u.created_at ? new Date(u.created_at).toLocaleDateString() : '—', + u.created_at ? new Date(u.created_at).toLocaleDateString() : '-', ]) ); } catch (e) { // If admin/users endpoint is not available, show a message document.getElementById('usersTable').innerHTML = - '
👥

User management API not available. Check server configuration.

'; + '
Users

User management API not available. Check server configuration.

'; } } @@ -358,8 +384,176 @@ function copyToClipboard(text) { }); } +// ---- Admin Users ---- +async function loadUsers() { + try { + const resp = await api.get('/admin/users'); + state.users = resp.users || []; + const tableDiv = document.getElementById('usersTable'); + if (state.users.length === 0) { + tableDiv.innerHTML = '

No users found.

'; + return; + } + tableDiv.innerHTML = renderTable( + ['ID', 'Email', 'Name', 'Role', 'Verified', 'Admin', 'Actions'], + state.users.map(u => [ + u.id, + escapeHtml(u.email), + escapeHtml(u.name || '-'), + u.role ? `${escapeHtml(u.role)}` : 'member', + u.email_verified ? 'verified' : 'pending', + u.is_admin ? 'admin' : 'user', + ` + `, + ]) + ); + } catch { + document.getElementById('usersTable').innerHTML = + '

Admin access required for user management.

'; + } +} + +async function verifyUserEmail(userId) { + try { + await api.post(`/admin/users/${userId}/verify-email`, {}); + showToast('User email verified', 'success'); + loadUsers(); + } catch (e) { + showToast(e.message || 'Failed to verify email', 'error'); + } +} + +async function setUserAdmin(userId, isAdmin) { + try { + await api.post(`/admin/users/${userId}/admin`, { is_admin: isAdmin }); + showToast('Admin status updated', 'success'); + loadUsers(); + } catch (e) { + showToast(e.message || 'Failed to update admin status', 'error'); + } +} + +// ---- Organizations ---- +async function loadOrganizations() { + try { + const resp = await api.get('/organizations'); + state.organizations = resp.organizations || []; + const tableDiv = document.getElementById('organizationsTable'); + if (state.organizations.length === 0) { + tableDiv.innerHTML = '

No organizations found.

'; + document.getElementById('organizationMembersTable').innerHTML = ''; + return; + } + tableDiv.innerHTML = renderTable( + ['ID', 'Name', 'Type', 'Your Role', 'Actions'], + state.organizations.map(m => [ + m.organization.id, + escapeHtml(m.organization.name), + escapeHtml(m.organization.organization_type), + `${escapeHtml(m.role)}`, + ` + `, + ]) + ); + loadOrganizationMembers(state.organizations[0].organization.id); + } catch { + document.getElementById('organizationsTable').innerHTML = + '

Failed to load organizations.

'; + } +} + +async function loadOrganizationMembers(orgId) { + try { + const resp = await api.get(`/organizations/${orgId}/users`); + const users = resp.users || []; + document.getElementById('organizationMembersTable').innerHTML = renderTable( + ['Email', 'Name', 'Role', 'Verified', 'Admin'], + users.map(u => [ + escapeHtml(u.email), + escapeHtml(u.name || '-'), + `${escapeHtml(u.role)}`, + u.email_verified ? 'verified' : 'pending', + u.is_admin ? 'admin' : 'user', + ]) + ); + } catch { + document.getElementById('organizationMembersTable').innerHTML = + '

Organization admin access required to view members.

'; + } +} + +async function createOrganization(name) { + try { + await api.post('/organizations', { name, organization_type: 'team' }); + closeModal('createOrgOverlay'); + showToast('Organization created', 'success'); + loadOrganizations(); + } catch (e) { + showToast(e.message || 'Failed to create organization', 'error'); + } +} + +function openCreateOrgModal() { + document.getElementById('orgName').value = ''; + openModal('createOrgOverlay'); +} + +function openAddOrgUserModal(orgId) { + document.getElementById('orgUserOrgId').value = orgId; + document.getElementById('orgUserEmail').value = ''; + document.getElementById('orgUserRole').value = 'member'; + openModal('addOrgUserOverlay'); +} + +async function addOrganizationUser(orgId, email, role) { + try { + await api.post(`/organizations/${orgId}/users`, { email, role }); + closeModal('addOrgUserOverlay'); + showToast('User added to organization', 'success'); + loadOrganizationMembers(orgId); + } catch (e) { + showToast(e.message || 'Failed to add user', 'error'); + } +} + +// ---- Settings override ---- +async function loadSettings() { + let backendInfo = { storage: 'Unknown', database: 'Unknown' }; + try { + const resp = await fetch('/health'); + const data = await resp.json(); + if (data.backend) backendInfo = data.backend; + } catch {} + + document.getElementById('serverInfo').innerHTML = ` +
Server Version
Shorebird Self-Hosted v1.0
+
API Base URL
${window.location.origin}/api/v1
+
Auth Endpoint
${window.location.origin}/auth
+
Storage Backend
${backendInfo.storage}
+
Database Backend
${backendInfo.database}
+
Current User
${escapeHtml(state.user?.email || '-')}
+ `; + document.getElementById('apiTokenDisplay').value = state.token || 'Not authenticated'; + + try { + const resp = await api.get('/admin/settings'); + const settings = resp.settings || {}; + document.getElementById('settingRegistrationEnabled').checked = settings.registration_enabled === 'true'; + document.getElementById('settingSsoRegistrationEnabled').checked = settings.sso_registration_enabled === 'true'; + document.getElementById('settingSsoOnlyRegistration').checked = settings.sso_only_registration === 'true'; + document.getElementById('casdoorEndpoint').value = settings.casdoor_endpoint || ''; + document.getElementById('casdoorClientId').value = settings.casdoor_client_id || ''; + document.getElementById('casdoorClientSecret').value = ''; + document.getElementById('casdoorOrganization').value = settings.casdoor_organization || 'built-in'; + } catch { + document.getElementById('adminSettingsForm').innerHTML = '

Admin access required for server settings.

'; + } +} + // ---- Event Listeners ---- document.addEventListener('DOMContentLoaded', () => { + loadPublicSettings(); + // Login form document.getElementById('loginForm').addEventListener('submit', async e => { e.preventDefault(); @@ -397,6 +591,12 @@ document.addEventListener('DOMContentLoaded', () => { openModal('registerOverlay'); }); + document.getElementById('showPasswordReset').addEventListener('click', e => { + e.preventDefault(); + document.getElementById('resetEmail').value = document.getElementById('loginEmail').value || ''; + openModal('passwordResetOverlay'); + }); + function closeRegister() { closeModal('registerOverlay'); } window.closeRegister = closeRegister; @@ -404,7 +604,8 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('createAppForm').addEventListener('submit', async e => { e.preventDefault(); const name = document.getElementById('appDisplayName').value; - await createApp(name); + const orgId = document.getElementById('appOrganizationId').value; + await createApp(name, orgId); }); // Create user form @@ -416,6 +617,85 @@ document.addEventListener('DOMContentLoaded', () => { await createUser(name, email, password); }); + document.getElementById('passwordResetForm').addEventListener('submit', async e => { + e.preventDefault(); + const email = document.getElementById('resetEmail').value; + try { + await api.request('POST', '/auth/password-reset/request', { email }); + closeModal('passwordResetOverlay'); + showToast('Password reset email sent if the account exists', 'success'); + } catch (err) { + showToast(err.message || 'Failed to request reset', 'error'); + } + }); + + document.getElementById('changePasswordForm').addEventListener('submit', async e => { + e.preventDefault(); + try { + await api.patch('/users/me/password', { + current_password: document.getElementById('currentPassword').value, + new_password: document.getElementById('newPassword').value, + }); + document.getElementById('changePasswordForm').reset(); + showToast('Password changed', 'success'); + } catch (err) { + showToast(err.message || 'Failed to change password', 'error'); + } + }); + + document.getElementById('forcePasswordForm').addEventListener('submit', async e => { + e.preventDefault(); + try { + await api.patch('/users/me/password', { + current_password: '', + new_password: document.getElementById('forceNewPassword').value, + }); + state.mustChangePassword = false; + if (state.user) state.user.must_change_password = false; + closeModal('forcePasswordOverlay'); + document.getElementById('forcePasswordForm').reset(); + showToast('Password updated', 'success'); + navigate('dashboard'); + } catch (err) { + showToast(err.message || 'Failed to update password', 'error'); + } + }); + + document.getElementById('adminSettingsForm').addEventListener('submit', async e => { + e.preventDefault(); + const body = { + registration_enabled: String(document.getElementById('settingRegistrationEnabled').checked), + sso_registration_enabled: String(document.getElementById('settingSsoRegistrationEnabled').checked), + sso_only_registration: String(document.getElementById('settingSsoOnlyRegistration').checked), + casdoor_endpoint: document.getElementById('casdoorEndpoint').value, + casdoor_client_id: document.getElementById('casdoorClientId').value, + casdoor_client_secret: document.getElementById('casdoorClientSecret').value, + casdoor_organization: document.getElementById('casdoorOrganization').value, + }; + try { + await api.request('PUT', '/admin/settings', body); + showToast('Settings saved', 'success'); + loadPublicSettings(); + loadSettings(); + } catch (err) { + showToast(err.message || 'Failed to save settings', 'error'); + } + }); + + document.getElementById('createOrgForm').addEventListener('submit', async e => { + e.preventDefault(); + await createOrganization(document.getElementById('orgName').value); + }); + + document.getElementById('addOrgUserForm').addEventListener('submit', async e => { + e.preventDefault(); + await addOrganizationUser( + document.getElementById('orgUserOrgId').value, + document.getElementById('orgUserEmail').value, + document.getElementById('orgUserRole').value, + ); + }); + // Sidebar navigation document.querySelectorAll('#sidebarNav a').forEach(link => { link.addEventListener('click', e => { @@ -443,17 +723,252 @@ document.addEventListener('DOMContentLoaded', () => { if (state.token) { loadCurrentUser().then(() => { showApp(); - const page = window.location.hash.replace('#', '') || 'dashboard'; - navigate(page); + if (state.user.must_change_password) { + navigate('settings'); + openModal('forcePasswordOverlay'); + } else { + const page = window.location.hash.replace('#', '') || 'dashboard'; + navigate(page); + } }).catch(() => { logout(); }); } }); +// ---- App Detail Views ---- +function setActivePage(page) { + document.querySelectorAll('.page-section').forEach(s => s.classList.remove('active')); + const section = document.getElementById(`page-${page}`); + if (section) section.classList.add('active'); +} + +function platformBadges(statuses) { + const entries = Object.entries(statuses || {}); + if (entries.length === 0) return 'no platforms'; + return entries.map(([platform, status]) => `${escapeHtml(platform)}`).join(' '); +} + +function humanSize(bytes) { + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit++; } + return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; +} + +async function loadApps() { + try { + const resp = await api.get('/apps'); + state.apps = resp.apps || []; + const tableDiv = document.getElementById('appsTable'); + if (state.apps.length === 0) { + tableDiv.innerHTML = '

No apps yet. Create your first app.

'; + return; + } + tableDiv.innerHTML = renderTable( + ['App Name', 'App ID', 'Created', 'Actions'], + state.apps.map(a => [ + ``, + `${a.app_id}`, + new Date(a.created_at).toLocaleDateString(), + ` + `, + ]) + ); + } catch { + showToast('Failed to load apps', 'error'); + } +} + +async function openAppPage(appId) { + const app = state.apps.find(a => a.app_id === appId) || { app_id: appId, display_name: appId }; + state.currentApp = app; + setActivePage('app-detail'); + document.getElementById('pageTitle').textContent = app.display_name; + document.getElementById('appDetailTitle').textContent = app.display_name; + showAppTab('releases'); +} + +async function showAppTab(tab) { + document.querySelectorAll('[data-app-tab]').forEach(b => b.classList.toggle('active', b.dataset.appTab === tab)); + document.querySelectorAll('.app-tab').forEach(el => { el.style.display = el.id === `appTab-${tab}` ? 'block' : 'none'; }); + if (!state.currentApp) return; + if (tab === 'releases') await renderAppReleases(); + if (tab === 'insights') await renderAppInsights(); + if (tab === 'collaborators') await renderAppCollaborators(); + if (tab === 'tracks') await renderAppTracks(); + if (tab === 'settings') await renderAppSettings(); +} + +async function renderAppReleases() { + const div = document.getElementById('appTab-releases'); + const resp = await api.get(`/apps/${state.currentApp.app_id}/releases`); + state.currentApp.releases = resp.releases || []; + if (state.currentApp.releases.length === 0) { + div.innerHTML = '

No releases yet.

'; + return; + } + div.innerHTML = renderTable( + ['Version', 'Platforms', 'Flutter', 'Created', 'Actions'], + state.currentApp.releases.map(r => [ + ``, + platformBadges(r.platform_statuses), + escapeHtml(r.flutter_version || r.flutter_revision || '-'), + new Date(r.created_at).toLocaleDateString(), + ``, + ]) + ); +} + +async function renderAppInsights() { + const releases = state.currentApp.releases || (await api.get(`/apps/${state.currentApp.app_id}/releases`)).releases || []; + document.getElementById('appTab-insights').innerHTML = ` +
+
${releases.length}
Releases
+
${new Set(releases.flatMap(r => Object.keys(r.platform_statuses || {}))).size}
Platforms
+
0
Downloads tracked
+
+

Download metrics are shown when artifact/download events are recorded.

`; +} + +async function renderAppCollaborators() { + const orgs = await api.get('/organizations'); + const membership = (orgs.organizations || []).find(m => m.organization.id === state.currentApp.organization_id); + if (!membership) { + document.getElementById('appTab-collaborators').innerHTML = '

No organization membership found.

'; + return; + } + try { + const resp = await api.get(`/organizations/${membership.organization.id}/users`); + document.getElementById('appTab-collaborators').innerHTML = renderTable( + ['Email', 'Name', 'Role'], + (resp.users || []).map(u => [escapeHtml(u.email), escapeHtml(u.name || '-'), `${escapeHtml(u.role)}`]) + ); + } catch { + document.getElementById('appTab-collaborators').innerHTML = '

Organization admin access required.

'; + } +} + +async function renderAppTracks() { + try { + const channels = await api.get(`/apps/${state.currentApp.app_id}/channels`); + document.getElementById('appTab-tracks').innerHTML = renderTable( + ['Track', 'ID'], + (channels || []).map(c => [escapeHtml(c.name), c.id]) + ); + } catch { + document.getElementById('appTab-tracks').innerHTML = '

No tracks found.

'; + } +} + +async function renderAppSettings() { + const orgs = await api.get('/organizations').catch(() => ({ organizations: [] })); + const options = (orgs.organizations || []).map(m => ``).join(''); + document.getElementById('appTab-settings').innerHTML = ` +
+ + `; +} + +async function transferCurrentApp() { + const orgId = Number(document.getElementById('transferOrgId').value); + await api.patch(`/apps/${state.currentApp.app_id}/transfer`, { organization_id: orgId }); + state.currentApp.organization_id = orgId; + showToast('App ownership transferred', 'success'); +} + +function confirmDeleteCurrentApp() { + confirmAction('Delete App', `Delete "${state.currentApp.display_name}"?`, async () => { + await api.del(`/apps/${state.currentApp.app_id}`); + showToast('App deleted', 'success'); + navigate('apps'); + }); +} + +async function openReleasePage(releaseId) { + const releases = state.currentApp.releases || (await api.get(`/apps/${state.currentApp.app_id}/releases`)).releases || []; + state.currentRelease = releases.find(r => r.id === releaseId); + setActivePage('release-detail'); + document.getElementById('pageTitle').textContent = `Release ${state.currentRelease.version}`; + document.getElementById('releaseDetailTitle').textContent = `Release ${state.currentRelease.version}`; + showReleaseTab('overview'); +} + +async function showReleaseTab(tab) { + document.querySelectorAll('[data-release-tab]').forEach(b => b.classList.toggle('active', b.dataset.releaseTab === tab)); + document.querySelectorAll('.release-tab').forEach(el => { el.style.display = el.id === `releaseTab-${tab}` ? 'block' : 'none'; }); + if (tab === 'overview') await renderReleaseOverview(); + if (tab === 'insights') await renderReleaseInsights(); + if (tab === 'artifacts') await renderReleaseArtifacts(); + if (tab === 'settings') renderReleaseSettings(); +} + +async function renderReleaseOverview() { + const resp = await api.get(`/apps/${state.currentApp.app_id}/releases/${state.currentRelease.id}/patches`).catch(() => ({ patches: [] })); + document.getElementById('releaseTab-overview').innerHTML = ` +
+
${(resp.patches || []).length}
Patches
+
${Object.keys(state.currentRelease.platform_statuses || {}).length}
Platforms
+
+ ${renderTable(['Patch', 'Created'], (resp.patches || []).map(p => [p.patch_number || p.number || p.id, p.created_at ? new Date(p.created_at).toLocaleDateString() : '-']))}`; +} + +async function renderReleaseInsights() { + const artifacts = await api.get(`/apps/${state.currentApp.app_id}/releases/${state.currentRelease.id}/artifacts`).catch(() => ({ artifacts: [] })); + const bytes = (artifacts.artifacts || []).reduce((sum, a) => sum + (a.size || 0), 0); + document.getElementById('releaseTab-insights').innerHTML = ` +
+
${(artifacts.artifacts || []).length}
Artifacts
+
${humanSize(bytes)}
Artifact bytes
+
0
Downloads tracked
+
`; +} + +async function renderReleaseArtifacts() { + const resp = await api.get(`/apps/${state.currentApp.app_id}/releases/${state.currentRelease.id}/artifacts`); + document.getElementById('releaseTab-artifacts').innerHTML = renderTable( + ['Platform', 'Arch', 'Size', 'Hash', 'Actions'], + (resp.artifacts || []).map(a => [ + escapeHtml(a.platform), + escapeHtml(a.arch), + humanSize(a.size), + `${escapeHtml(a.hash || '-')}`, + `Download`, + ]) + ); +} + +function renderReleaseSettings() { + document.getElementById('releaseTab-settings').innerHTML = ``; +} + +function confirmDeleteCurrentRelease() { + confirmAction('Delete Release', `Delete release ${state.currentRelease.version}?`, async () => { + await api.del(`/apps/${state.currentApp.app_id}/releases/${state.currentRelease.id}`); + showToast('Release deleted', 'success'); + await openAppPage(state.currentApp.app_id); + }); +} + // Export functions for inline onclick handlers window.openCreateAppModal = openCreateAppModal; window.openCreateUserModal = openCreateUserModal; +window.openCreateOrgModal = openCreateOrgModal; +window.openAddOrgUserModal = openAddOrgUserModal; +window.loadOrganizationMembers = loadOrganizationMembers; +window.openAppPage = openAppPage; +window.showAppTab = showAppTab; +window.transferCurrentApp = transferCurrentApp; +window.confirmDeleteCurrentApp = confirmDeleteCurrentApp; +window.openReleasePage = openReleasePage; +window.showReleaseTab = showReleaseTab; +window.confirmDeleteCurrentRelease = confirmDeleteCurrentRelease; +window.verifyUserEmail = verifyUserEmail; +window.setUserAdmin = setUserAdmin; window.confirmDeleteApp = confirmDeleteApp; window.copyToClipboard = copyToClipboard; window.closeModal = closeModal; + +