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.
This commit is contained in:
Tony
2026-06-12 20:03:58 +08:00
parent 3bb4ab494e
commit 5f72264b06
19 changed files with 2236 additions and 208 deletions
+15
View File
@@ -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=
+54 -3
View File
@@ -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: <your-app-uuid>
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.
+21 -1
View File
@@ -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)
+127 -16
View File
@@ -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
}
+213 -9
View File
@@ -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
}
+332 -14
View File
@@ -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, `<html><body><p>Email verified. You can close this page and sign in.</p></body></html>`)
}
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, `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Reset Password</title>
<style>*{box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8fafc;display:flex;align-items:center;justify-content:center;min-height:100vh}.card{background:white;border:1px solid #e2e8f0;border-radius:8px;padding:32px;width:100%%;max-width:420px}label{display:block;font-size:13px;font-weight:600;color:#64748b;margin:14px 0 5px}input{width:100%%;padding:10px;border:1px solid #e2e8f0;border-radius:8px}button{margin-top:18px;width:100%%;padding:12px;border:0;border-radius:8px;background:#0891b2;color:white;font-weight:600}.msg{font-size:14px;margin-top:14px}</style></head>
<body><div class="card"><h2>Reset Password</h2><form id="f"><input type="hidden" id="token" value="%s"><label for="password">New password</label><input type="password" id="password" minlength="6" required><button type="submit">Update password</button><div class="msg" id="msg"></div></form></div>
<script>document.getElementById('f').addEventListener('submit',async e=>{e.preventDefault();const res=await fetch('/auth/password-reset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:document.getElementById('token').value,password:document.getElementById('password').value})});const data=await res.json().catch(()=>({message:'Password updated'}));document.getElementById('msg').textContent=res.ok?'Password updated. You can sign in now.':(data.message||'Failed to update password');});</script></body></html>`, 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, `<html><body><script>localStorage.setItem('shorebird_token', %q); location.href='/';</script></body></html>`, 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(`<div class="error">%s</div>`, errorMsg)
}
ssoHTML := ""
if ssoEnabled {
ssoHTML = fmt.Sprintf(`<a class="btn secondary" href="/auth/sso/login?continue=%s">Sign in with Casdoor</a>`, url.QueryEscape(continueURL))
}
fmt.Fprintf(w, `<!DOCTYPE html>
<html lang="en">
<head>
@@ -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}
</style>
</head>
@@ -315,7 +632,8 @@ func serveLoginHTML(w http.ResponseWriter, continueURL, errorMsg string) {
<div class="fg"><label for="password">Password</label><input type="password" id="password" name="password" placeholder="Enter password" required></div>
<button type="submit" class="btn">Sign In</button>
</form>
%s
</div>
</body>
</html>`, errorHTML)
</html>`, errorHTML, ssoHTML)
}
+16 -1
View File
@@ -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,
+25 -6
View File
@@ -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)
+19 -1
View File
@@ -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
}
+15 -1
View File
@@ -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))
+30
View File
@@ -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", ""),
+155 -14
View File
@@ -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
}
@@ -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;
+305 -54
View File
@@ -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
}
}
+55 -10
View File
@@ -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
}
+59
View File
@@ -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))
}
+50 -48
View File
@@ -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"`
}
+167 -1
View File
@@ -31,8 +31,10 @@
Sign In
</button>
</form>
<a href="/auth/sso/login?mode=web" id="ssoLoginBtn" class="btn btn-outline" style="display:none;width:100%;justify-content:center;margin-top:12px;">Sign in with Casdoor</a>
<p style="text-align:center;margin-top:16px;font-size:13px;color:var(--text-muted);">
No account? <a href="#" id="showRegister" style="color:var(--primary);">Register</a>
<a href="#" id="showPasswordReset" style="color:var(--primary);">Reset password</a>
<span id="registerPrompt"> · No account? <a href="#" id="showRegister" style="color:var(--primary);">Register</a></span>
</p>
</div>
</div>
@@ -83,6 +85,9 @@
<a href="#users" data-page="users">
<span class="nav-icon">👥</span> Users
</a>
<a href="#organizations" data-page="organizations">
<span class="nav-icon">🏢</span> Organizations
</a>
<a href="#settings" data-page="settings">
<span class="nav-icon">⚙️</span> Settings
</a>
@@ -148,16 +153,105 @@
</div>
</section>
<section id="page-organizations" class="page-section">
<div class="card">
<div class="card-header">
<h3>Organizations</h3>
<button class="btn btn-primary" onclick="openCreateOrgModal()">+ New Organization</button>
</div>
<div class="card-body" id="organizationsTable"></div>
</div>
<div class="card" style="margin-top:20px;">
<div class="card-header"><h3>Organization Members</h3></div>
<div class="card-body" id="organizationMembersTable"></div>
</div>
</section>
<section id="page-app-detail" class="page-section">
<div class="card">
<div class="card-header">
<h3 id="appDetailTitle">App</h3>
<button class="btn btn-outline" onclick="navigate('apps')">Back</button>
</div>
<div class="card-body" style="padding:24px;">
<div class="tab-bar">
<button class="active" data-app-tab="releases" onclick="showAppTab('releases')">Releases</button>
<button data-app-tab="insights" onclick="showAppTab('insights')">Insights</button>
<button data-app-tab="collaborators" onclick="showAppTab('collaborators')">Collaborators</button>
<button data-app-tab="tracks" onclick="showAppTab('tracks')">Tracks</button>
<button data-app-tab="settings" onclick="showAppTab('settings')">Settings</button>
</div>
<div id="appTab-releases" class="app-tab"></div>
<div id="appTab-insights" class="app-tab" style="display:none;"></div>
<div id="appTab-collaborators" class="app-tab" style="display:none;"></div>
<div id="appTab-tracks" class="app-tab" style="display:none;"></div>
<div id="appTab-settings" class="app-tab" style="display:none;"></div>
</div>
</div>
</section>
<section id="page-release-detail" class="page-section">
<div class="card">
<div class="card-header">
<h3 id="releaseDetailTitle">Release</h3>
<button class="btn btn-outline" onclick="openAppPage(state.currentApp?.app_id)">Back</button>
</div>
<div class="card-body" style="padding:24px;">
<div class="tab-bar">
<button class="active" data-release-tab="overview" onclick="showReleaseTab('overview')">Overview</button>
<button data-release-tab="insights" onclick="showReleaseTab('insights')">Insights</button>
<button data-release-tab="artifacts" onclick="showReleaseTab('artifacts')">Artifacts</button>
<button data-release-tab="settings" onclick="showReleaseTab('settings')">Settings</button>
</div>
<div id="releaseTab-overview" class="release-tab"></div>
<div id="releaseTab-insights" class="release-tab" style="display:none;"></div>
<div id="releaseTab-artifacts" class="release-tab" style="display:none;"></div>
<div id="releaseTab-settings" class="release-tab" style="display:none;"></div>
</div>
</div>
</section>
<!-- ================================================
SETTINGS PAGE
================================================ -->
<section id="page-settings" class="page-section">
<div class="card">
<div class="card-header"><h3>Personal Settings</h3></div>
<div class="card-body" style="padding:24px;">
<form id="changePasswordForm" style="max-width:520px;">
<div class="form-group">
<label for="currentPassword">Current Password</label>
<input type="password" id="currentPassword" required>
</div>
<div class="form-group">
<label for="newPassword">New Password</label>
<input type="password" id="newPassword" required minlength="6">
</div>
<button type="submit" class="btn btn-primary">Change Password</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><h3>Server Configuration</h3></div>
<div class="card-body" style="padding:24px;">
<div class="info-grid" id="serverInfo"></div>
</div>
</div>
<div class="card" style="margin-top:20px;">
<div class="card-header"><h3>Admin Settings</h3></div>
<div class="card-body" style="padding:24px;">
<form id="adminSettingsForm" style="max-width:720px;">
<div class="form-group"><label><input type="checkbox" id="settingRegistrationEnabled" style="width:auto;"> Enable password registration</label></div>
<div class="form-group"><label><input type="checkbox" id="settingSsoRegistrationEnabled" style="width:auto;"> Enable SSO registration</label></div>
<div class="form-group"><label><input type="checkbox" id="settingSsoOnlyRegistration" style="width:auto;"> SSO-only registration</label></div>
<div class="form-group"><label for="casdoorEndpoint">Casdoor endpoint</label><input type="url" id="casdoorEndpoint" placeholder="https://casdoor.example.com"></div>
<div class="form-group"><label for="casdoorClientId">Casdoor app ID</label><input type="text" id="casdoorClientId"></div>
<div class="form-group"><label for="casdoorClientSecret">Casdoor app secret</label><input type="password" id="casdoorClientSecret" placeholder="Leave blank to keep current secret"></div>
<div class="form-group"><label for="casdoorOrganization">Casdoor organization</label><input type="text" id="casdoorOrganization" placeholder="built-in"></div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>
<div class="card" style="margin-top:20px;">
<div class="card-header"><h3>API Token</h3></div>
<div class="card-body" style="padding:24px;">
@@ -190,6 +284,10 @@
<label for="appDisplayName">Display Name</label>
<input type="text" id="appDisplayName" placeholder="My Flutter App" required>
</div>
<div class="form-group">
<label for="appOrganizationId">Organization</label>
<select id="appOrganizationId"></select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('createAppOverlay')">Cancel</button>
<button type="submit" class="btn btn-primary">Create App</button>
@@ -235,6 +333,74 @@
</div>
</div>
<div id="passwordResetOverlay" class="modal-overlay">
<div class="modal">
<h3>Reset Password</h3>
<form id="passwordResetForm">
<div class="form-group">
<label for="resetEmail">Email</label>
<input type="email" id="resetEmail" placeholder="you@example.com" required>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('passwordResetOverlay')">Cancel</button>
<button type="submit" class="btn btn-primary">Send Reset Link</button>
</div>
</form>
</div>
</div>
<div id="forcePasswordOverlay" class="modal-overlay">
<div class="modal">
<h3>Change Default Password</h3>
<form id="forcePasswordForm">
<div class="form-group">
<label for="forceNewPassword">New Password</label>
<input type="password" id="forceNewPassword" required minlength="6">
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Update Password</button>
</div>
</form>
</div>
</div>
<div id="createOrgOverlay" class="modal-overlay">
<div class="modal">
<h3>Create Organization</h3>
<form id="createOrgForm">
<div class="form-group">
<label for="orgName">Name</label>
<input type="text" id="orgName" placeholder="Team name" required>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('createOrgOverlay')">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
<div id="addOrgUserOverlay" class="modal-overlay">
<div class="modal">
<h3>Add Organization User</h3>
<form id="addOrgUserForm">
<input type="hidden" id="orgUserOrgId">
<div class="form-group">
<label for="orgUserEmail">Email</label>
<input type="email" id="orgUserEmail" required>
</div>
<div class="form-group">
<label for="orgUserRole">Role</label>
<select id="orgUserRole"><option value="member">Member</option><option value="admin">Admin</option></select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('addOrgUserOverlay')">Cancel</button>
<button type="submit" class="btn btn-primary">Add User</button>
</div>
</form>
</div>
</div>
<script src="/web/js/app.js"></script>
</body>
</html>
+544 -29
View File
@@ -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 = '<div class="empty-state"><div class="empty-icon">📱</div><p>No apps yet. Create your first app to get started.</p></div>';
tableDiv.innerHTML = '<div class="empty-state"><div class="empty-icon">Apps</div><p>No apps yet. Create your first app to get started.</p></div>';
} 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 = '<div class="empty-state"><div class="empty-icon">📱</div><p>No apps yet. Create your first app.</p></div>';
tableDiv.innerHTML = '<div class="empty-state"><div class="empty-icon">Apps</div><p>No apps yet. Create your first app.</p></div>';
return;
}
@@ -196,8 +212,8 @@ async function loadApps() {
a.display_name,
`<code class="code">${a.app_id}</code>`,
new Date(a.created_at).toLocaleDateString(),
`<button class="btn btn-ghost" onclick="copyToClipboard('${a.app_id}')" title="Copy App ID">📋</button>
<button class="btn btn-ghost" onclick="confirmDeleteApp('${a.app_id}','${escapeHtml(a.display_name)}')" title="Delete App">🗑️</button>`,
`<button class="btn btn-ghost" onclick="copyToClipboard('${a.app_id}')" title="Copy App ID">Copy</button>
<button class="btn btn-ghost" onclick="confirmDeleteApp('${a.app_id}','${escapeHtml(a.display_name)}')" title="Delete App">Delete</button>`,
])
);
} 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 = '<option value="0">Default organization</option>';
api.get('/organizations').then(resp => {
state.organizations = resp.organizations || [];
if (state.organizations.length > 0) {
select.innerHTML = state.organizations
.map(m => `<option value="${m.organization.id}">${escapeHtml(m.organization.name)}</option>`)
.join('');
}
}).catch(() => {});
openModal('createAppOverlay');
}
@@ -243,7 +269,7 @@ async function loadUsers() {
const tableDiv = document.getElementById('usersTable');
if (state.users.length === 0) {
tableDiv.innerHTML = '<div class="empty-state"><div class="empty-icon">👥</div><p>No users found.</p></div>';
tableDiv.innerHTML = '<div class="empty-state"><div class="empty-icon">Users</div><p>No users found.</p></div>';
return;
}
@@ -252,15 +278,15 @@ async function loadUsers() {
state.users.map(u => [
u.id,
u.email,
u.name || '',
u.name || '-',
u.role ? `<span class="badge badge-info">${u.role}</span>` : `<span class="badge">member</span>`,
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 =
'<div class="empty-state"><div class="empty-icon">👥</div><p>User management API not available. Check server configuration.</p></div>';
'<div class="empty-state"><div class="empty-icon">Users</div><p>User management API not available. Check server configuration.</p></div>';
}
}
@@ -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 = '<div class="empty-state"><p>No users found.</p></div>';
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 ? `<span class="badge badge-info">${escapeHtml(u.role)}</span>` : '<span class="badge">member</span>',
u.email_verified ? '<span class="badge badge-success">verified</span>' : '<span class="badge badge-warning">pending</span>',
u.is_admin ? '<span class="badge badge-success">admin</span>' : '<span class="badge">user</span>',
`<button class="btn btn-ghost" onclick="verifyUserEmail(${u.id})">Verify</button>
<button class="btn btn-ghost" onclick="setUserAdmin(${u.id}, ${!u.is_admin})">${u.is_admin ? 'Revoke admin' : 'Make admin'}</button>`,
])
);
} catch {
document.getElementById('usersTable').innerHTML =
'<div class="empty-state"><p>Admin access required for user management.</p></div>';
}
}
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 = '<div class="empty-state"><p>No organizations found.</p></div>';
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),
`<span class="badge badge-info">${escapeHtml(m.role)}</span>`,
`<button class="btn btn-ghost" onclick="loadOrganizationMembers(${m.organization.id})">Members</button>
<button class="btn btn-ghost" onclick="openAddOrgUserModal(${m.organization.id})">Add user</button>`,
])
);
loadOrganizationMembers(state.organizations[0].organization.id);
} catch {
document.getElementById('organizationsTable').innerHTML =
'<div class="empty-state"><p>Failed to load organizations.</p></div>';
}
}
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 || '-'),
`<span class="badge badge-info">${escapeHtml(u.role)}</span>`,
u.email_verified ? '<span class="badge badge-success">verified</span>' : '<span class="badge badge-warning">pending</span>',
u.is_admin ? '<span class="badge badge-success">admin</span>' : '<span class="badge">user</span>',
])
);
} catch {
document.getElementById('organizationMembersTable').innerHTML =
'<div class="empty-state"><p>Organization admin access required to view members.</p></div>';
}
}
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 = `
<div class="info-item"><div class="info-label">Server Version</div><div class="info-value">Shorebird Self-Hosted v1.0</div></div>
<div class="info-item"><div class="info-label">API Base URL</div><div class="info-value"><code class="code">${window.location.origin}/api/v1</code></div></div>
<div class="info-item"><div class="info-label">Auth Endpoint</div><div class="info-value"><code class="code">${window.location.origin}/auth</code></div></div>
<div class="info-item"><div class="info-label">Storage Backend</div><div class="info-value"><span class="badge badge-info">${backendInfo.storage}</span></div></div>
<div class="info-item"><div class="info-label">Database Backend</div><div class="info-value"><span class="badge badge-success">${backendInfo.database}</span></div></div>
<div class="info-item"><div class="info-label">Current User</div><div class="info-value">${escapeHtml(state.user?.email || '-')}</div></div>
`;
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 = '<p style="color:var(--text-muted);font-size:14px;">Admin access required for server settings.</p>';
}
}
// ---- 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 '<span class="badge">no platforms</span>';
return entries.map(([platform, status]) => `<span class="badge badge-info" title="${escapeHtml(status)}">${escapeHtml(platform)}</span>`).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 = '<div class="empty-state"><p>No apps yet. Create your first app.</p></div>';
return;
}
tableDiv.innerHTML = renderTable(
['App Name', 'App ID', 'Created', 'Actions'],
state.apps.map(a => [
`<button class="btn btn-ghost" onclick="openAppPage('${a.app_id}')">${escapeHtml(a.display_name)}</button>`,
`<code class="code">${a.app_id}</code>`,
new Date(a.created_at).toLocaleDateString(),
`<button class="btn btn-ghost" onclick="openAppPage('${a.app_id}')">Open</button>
<button class="btn btn-ghost" onclick="copyToClipboard('${a.app_id}')">Copy</button>`,
])
);
} 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 = '<div class="empty-state"><p>No releases yet.</p></div>';
return;
}
div.innerHTML = renderTable(
['Version', 'Platforms', 'Flutter', 'Created', 'Actions'],
state.currentApp.releases.map(r => [
`<button class="btn btn-ghost" onclick="openReleasePage(${r.id})">${escapeHtml(r.version)}</button>`,
platformBadges(r.platform_statuses),
escapeHtml(r.flutter_version || r.flutter_revision || '-'),
new Date(r.created_at).toLocaleDateString(),
`<button class="btn btn-ghost" onclick="openReleasePage(${r.id})">Open</button>`,
])
);
}
async function renderAppInsights() {
const releases = state.currentApp.releases || (await api.get(`/apps/${state.currentApp.app_id}/releases`)).releases || [];
document.getElementById('appTab-insights').innerHTML = `
<div class="stats-grid">
<div class="stat-card primary"><div class="stat-value">${releases.length}</div><div class="stat-label">Releases</div></div>
<div class="stat-card success"><div class="stat-value">${new Set(releases.flatMap(r => Object.keys(r.platform_statuses || {}))).size}</div><div class="stat-label">Platforms</div></div>
<div class="stat-card warning"><div class="stat-value">0</div><div class="stat-label">Downloads tracked</div></div>
</div>
<p class="form-hint">Download metrics are shown when artifact/download events are recorded.</p>`;
}
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 = '<div class="empty-state"><p>No organization membership found.</p></div>';
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 || '-'), `<span class="badge badge-info">${escapeHtml(u.role)}</span>`])
);
} catch {
document.getElementById('appTab-collaborators').innerHTML = '<div class="empty-state"><p>Organization admin access required.</p></div>';
}
}
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 = '<div class="empty-state"><p>No tracks found.</p></div>';
}
}
async function renderAppSettings() {
const orgs = await api.get('/organizations').catch(() => ({ organizations: [] }));
const options = (orgs.organizations || []).map(m => `<option value="${m.organization.id}" ${m.organization.id === state.currentApp.organization_id ? 'selected' : ''}>${escapeHtml(m.organization.name)}</option>`).join('');
document.getElementById('appTab-settings').innerHTML = `
<div class="form-group"><label for="transferOrgId">Transfer ownership to organization</label><select id="transferOrgId">${options}</select></div>
<button class="btn btn-primary" onclick="transferCurrentApp()">Transfer Ownership</button>
<button class="btn btn-danger" style="margin-left:8px;" onclick="confirmDeleteCurrentApp()">Delete App</button>`;
}
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 = `
<div class="stats-grid">
<div class="stat-card primary"><div class="stat-value">${(resp.patches || []).length}</div><div class="stat-label">Patches</div></div>
<div class="stat-card success"><div class="stat-value">${Object.keys(state.currentRelease.platform_statuses || {}).length}</div><div class="stat-label">Platforms</div></div>
</div>
${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 = `
<div class="stats-grid">
<div class="stat-card primary"><div class="stat-value">${(artifacts.artifacts || []).length}</div><div class="stat-label">Artifacts</div></div>
<div class="stat-card success"><div class="stat-value">${humanSize(bytes)}</div><div class="stat-label">Artifact bytes</div></div>
<div class="stat-card warning"><div class="stat-value">0</div><div class="stat-label">Downloads tracked</div></div>
</div>`;
}
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),
`<code class="code">${escapeHtml(a.hash || '-')}</code>`,
`<a class="btn btn-ghost" href="${a.url}" target="_blank" rel="noopener">Download</a>`,
])
);
}
function renderReleaseSettings() {
document.getElementById('releaseTab-settings').innerHTML = `<button class="btn btn-danger" onclick="confirmDeleteCurrentRelease()">Delete Release</button>`;
}
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;