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:
@@ -31,6 +31,21 @@ STORAGE_LOCAL_DIR=data/storage
|
|||||||
# --- Authentication ---
|
# --- Authentication ---
|
||||||
JWT_SECRET=change-me-in-production-use-a-long-random-string
|
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 (optional) ---
|
||||||
# REDIS_ADDR=localhost:6379
|
# REDIS_ADDR=localhost:6379
|
||||||
# REDIS_PASSWORD=
|
# REDIS_PASSWORD=
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ A self-hosted replacement for the Shorebird CodePush API server (`api.shorebird.
|
|||||||
|
|
||||||
## Quick Start (Zero Dependencies)
|
## 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.
|
no Postgres, no MinIO needed.
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -21,7 +21,7 @@ and file storage are auto-created under the `data/` directory.
|
|||||||
|
|
||||||
### 2. Open the Web Dashboard
|
### 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
|
### 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! |
|
| `JWT_SECRET` | *(insecure default)* | Set in production! |
|
||||||
| `SERVER_PORT` | `8080` | HTTP port |
|
| `SERVER_PORT` | `8080` | HTTP port |
|
||||||
| `SERVER_BASE_URL` | `http://localhost:8080` | Public URL for download links |
|
| `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
|
### 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`
|
### 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
|
```yaml
|
||||||
app_id: <your-app-uuid>
|
app_id: <your-app-uuid>
|
||||||
base_url: http://your-server.com:8080
|
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/register` | Register a new user |
|
||||||
| POST | `/auth/token` | Login (get JWT) |
|
| POST | `/auth/token` | Login (get JWT) |
|
||||||
| POST | `/auth/refresh` | Refresh 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)
|
### API v1 (JWT required unless noted)
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/v1/users/me` | Get current user |
|
| 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 |
|
| POST | `/api/v1/users` | Create user |
|
||||||
| GET | `/api/v1/apps` | List apps |
|
| GET | `/api/v1/apps` | List apps |
|
||||||
| POST | `/api/v1/apps` | Create app |
|
| POST | `/api/v1/apps` | Create app |
|
||||||
| DELETE | `/api/v1/apps/{appId}` | Delete 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 |
|
| POST | `/api/v1/apps/{appId}/channels` | Create channel |
|
||||||
| GET | `/api/v1/apps/{appId}/channels` | List channels |
|
| GET | `/api/v1/apps/{appId}/channels` | List channels |
|
||||||
| POST | `/api/v1/apps/{appId}/releases` | Create release |
|
| POST | `/api/v1/apps/{appId}/releases` | Create release |
|
||||||
| GET | `/api/v1/apps/{appId}/releases` | List releases |
|
| GET | `/api/v1/apps/{appId}/releases` | List releases |
|
||||||
| PATCH | `/api/v1/apps/{appId}/releases/{releaseId}` | Update release |
|
| 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 |
|
| POST | `/api/v1/apps/{appId}/releases/{releaseId}/artifacts` | Upload release artifact |
|
||||||
| GET | `/api/v1/apps/{appId}/releases/{releaseId}/artifacts` | Get release artifacts |
|
| GET | `/api/v1/apps/{appId}/releases/{releaseId}/artifacts` | Get release artifacts |
|
||||||
| POST | `/api/v1/apps/{appId}/patches` | Create patch |
|
| 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 |
|
| GET | `/api/v1/apps/{appId}/releases/{releaseId}/patches` | List patches |
|
||||||
| POST | `/api/v1/apps/{appId}/patches/promote` | Promote patch to channel |
|
| POST | `/api/v1/apps/{appId}/patches/promote` | Promote patch to channel |
|
||||||
| GET | `/api/v1/organizations` | List organizations |
|
| 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/check`** | **Device patch check (public)** |
|
||||||
| **POST** | **`/api/v1/patches/events`** | **Device patch event (public)** |
|
| **POST** | **`/api/v1/patches/events`** | **Device patch event (public)** |
|
||||||
| GET | `/api/v1/diagnostics/gcp_upload` | Speed test (stub) |
|
| 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 |
|
| GET | `/api/v1/admin/patches/{patchId}/target-devices` | List targeted devices |
|
||||||
| DELETE | `/api/v1/admin/patches/{patchId}/target-devices/{clientId}` | Remove device restriction |
|
| 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
|
## Environment Variables
|
||||||
|
|
||||||
See `.env.example` for all available configuration options.
|
See `.env.example` for all available configuration options.
|
||||||
|
|||||||
+21
-1
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/shorebird-server/internal/auth"
|
"github.com/shorebird-server/internal/auth"
|
||||||
"github.com/shorebird-server/internal/config"
|
"github.com/shorebird-server/internal/config"
|
||||||
"github.com/shorebird-server/internal/db"
|
"github.com/shorebird-server/internal/db"
|
||||||
|
"github.com/shorebird-server/internal/email"
|
||||||
"github.com/shorebird-server/internal/storage"
|
"github.com/shorebird-server/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,8 +37,27 @@ func main() {
|
|||||||
defer database.Close()
|
defer database.Close()
|
||||||
log.Printf("Database: %s (%s)", cfg.DB.Driver, cfg.DB.Path)
|
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 ---
|
// --- Auth ---
|
||||||
authService := auth.NewService(cfg.Auth.JWTSecret, cfg.Auth.TokenDuration)
|
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 ---
|
// --- Storage ---
|
||||||
store, err := storage.NewStore(storage.Config{
|
store, err := storage.NewStore(storage.Config{
|
||||||
@@ -57,7 +77,7 @@ func main() {
|
|||||||
log.Printf("Storage: %s", store.BackendName())
|
log.Printf("Storage: %s", store.BackendName())
|
||||||
|
|
||||||
// --- Router ---
|
// --- Router ---
|
||||||
router := handlers.NewRouter(authService, database, store)
|
router := handlers.NewRouter(authService, database, store, mailer, cfg.Server.BaseURL)
|
||||||
|
|
||||||
// --- HTTP Server ---
|
// --- HTTP Server ---
|
||||||
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
|
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
|
||||||
|
|||||||
+127
-16
@@ -61,8 +61,8 @@ func (h *AdminHandler) GetTargetDevices(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"patch_id": patchID,
|
"patch_id": patchID,
|
||||||
"client_ids": devices,
|
"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)
|
// ListUsers handles GET /api/v1/admin/users (admin convenience endpoint)
|
||||||
func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.GetClaims(r)
|
if !h.requireAdmin(w, r) {
|
||||||
if claims == nil {
|
|
||||||
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,18 +86,19 @@ func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"message": "Admin endpoints available",
|
"message": "Admin endpoints available",
|
||||||
"user": claims.Email,
|
|
||||||
"users": []interface{}{},
|
"users": []interface{}{},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type userEntry struct {
|
type userEntry struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]userEntry, 0, len(users))
|
result := make([]userEntry, 0, len(users))
|
||||||
@@ -109,11 +108,13 @@ func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
role = "member"
|
role = "member"
|
||||||
}
|
}
|
||||||
result = append(result, userEntry{
|
result = append(result, userEntry{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Email: u.Email,
|
Email: u.Email,
|
||||||
Name: u.Name,
|
Name: u.Name,
|
||||||
Role: role,
|
Role: role,
|
||||||
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
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,
|
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/shorebird-server/internal/api/middleware"
|
"github.com/shorebird-server/internal/api/middleware"
|
||||||
|
"github.com/shorebird-server/internal/auth"
|
||||||
"github.com/shorebird-server/internal/db"
|
"github.com/shorebird-server/internal/db"
|
||||||
"github.com/shorebird-server/internal/models"
|
"github.com/shorebird-server/internal/models"
|
||||||
)
|
)
|
||||||
@@ -22,7 +25,8 @@ func intPtr(i int) *int {
|
|||||||
|
|
||||||
// UserHandler handles user-related API endpoints.
|
// UserHandler handles user-related API endpoints.
|
||||||
type UserHandler struct {
|
type UserHandler struct {
|
||||||
DB db.Store
|
DB db.Store
|
||||||
|
BaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentUser handles GET /api/v1/users/me
|
// GetCurrentUser handles GET /api/v1/users/me
|
||||||
@@ -43,13 +47,52 @@ func (h *UserHandler) GetCurrentUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"display_name": user.Name,
|
"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,
|
"has_active_subscription": true,
|
||||||
"stripe_customer_id": nil,
|
"stripe_customer_id": nil,
|
||||||
"patch_overage_limit": 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
|
// CreateUser handles POST /api/v1/users
|
||||||
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.GetClaims(r)
|
claims := middleware.GetClaims(r)
|
||||||
@@ -103,13 +146,14 @@ func (h *AppHandler) GetApps(w http.ResponseWriter, r *http.Request) {
|
|||||||
var result []models.AppMetadata
|
var result []models.AppMetadata
|
||||||
for _, a := range apps {
|
for _, a := range apps {
|
||||||
result = append(result, models.AppMetadata{
|
result = append(result, models.AppMetadata{
|
||||||
AppID: a.ID.String(),
|
AppID: a.ID.String(),
|
||||||
DisplayName: a.DisplayName,
|
OrganizationID: a.OrganizationID,
|
||||||
CreatedAt: a.CreatedAt.Time,
|
DisplayName: a.DisplayName,
|
||||||
UpdatedAt: a.UpdatedAt.Time,
|
CreatedAt: a.CreatedAt.Time,
|
||||||
Platforms: []string{},
|
UpdatedAt: a.UpdatedAt.Time,
|
||||||
LatestReleases: map[string]models.LatestRelease{},
|
Platforms: []string{},
|
||||||
PendingReleases: map[string]models.PendingRelease{},
|
LatestReleases: map[string]models.LatestRelease{},
|
||||||
|
PendingReleases: map[string]models.PendingRelease{},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
@@ -149,6 +193,10 @@ func (h *AppHandler) CreateApp(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.OrganizationID = orgs[0].OrgID
|
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)
|
app, err := h.DB.CreateApp(r.Context(), req.OrganizationID, req.DisplayName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -172,12 +220,26 @@ func (h *AppHandler) CreateApp(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// DeleteApp handles DELETE /api/v1/apps/{appId}
|
// DeleteApp handles DELETE /api/v1/apps/{appId}
|
||||||
func (h *AppHandler) DeleteApp(w http.ResponseWriter, r *http.Request) {
|
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")
|
appID := chi.URLParam(r, "appId")
|
||||||
id, err := parseUUID(appID)
|
id, err := parseUUID(appID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||||
return
|
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 {
|
if err := h.DB.DeleteApp(r.Context(), id); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to delete app", 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)
|
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.
|
// ChannelHandler handles channel-related API endpoints.
|
||||||
type ChannelHandler struct {
|
type ChannelHandler struct {
|
||||||
DB db.Store
|
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})
|
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
@@ -2,9 +2,14 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,6 +17,7 @@ import (
|
|||||||
"github.com/shorebird-server/internal/api/middleware"
|
"github.com/shorebird-server/internal/api/middleware"
|
||||||
"github.com/shorebird-server/internal/auth"
|
"github.com/shorebird-server/internal/auth"
|
||||||
"github.com/shorebird-server/internal/db"
|
"github.com/shorebird-server/internal/db"
|
||||||
|
"github.com/shorebird-server/internal/email"
|
||||||
"github.com/shorebird-server/internal/models"
|
"github.com/shorebird-server/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,6 +80,13 @@ type oauthTokenResponse struct {
|
|||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
DB db.Store
|
DB db.Store
|
||||||
AuthService *auth.Service
|
AuthService *auth.Service
|
||||||
|
Mailer *email.Mailer
|
||||||
|
BaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type casdoorProfile struct {
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginPage handles GET /auth/login — OAuth HTML login form.
|
// 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")
|
email := r.FormValue("email")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
if email == "" || password == "" {
|
if email == "" || password == "" {
|
||||||
serveLoginHTML(w, continueURL, "Email and password are required.")
|
serveLoginHTML(w, continueURL, "Email and password are required.", h.ssoEnabled(r))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := h.DB.GetUserByEmail(r.Context(), email)
|
user, err := h.DB.GetUserByEmail(r.Context(), email)
|
||||||
if err != nil || auth.CheckPassword(user.PasswordHash, password) != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
code := globalCodeStore.set(user.Email)
|
code := globalCodeStore.set(user.Email)
|
||||||
@@ -101,7 +118,7 @@ func (h *AuthHandler) LoginPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
serveLoginHTML(w, continueURL, "")
|
serveLoginHTML(w, continueURL, "", h.ssoEnabled(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login handles POST /auth/token — supports OAuth form-encoded + JSON body.
|
// 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)
|
respondError(w, http.StatusInternalServerError, "User not found", nil)
|
||||||
return
|
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)
|
h.writeOAuthTokenResponse(w, user.ID, user.Email)
|
||||||
|
|
||||||
case "refresh_token":
|
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)
|
respondError(w, http.StatusBadRequest, "Invalid refresh token", nil)
|
||||||
return
|
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)
|
h.writeOAuthTokenResponse(w, claims.UserID, claims.Email)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -171,6 +197,10 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusUnauthorized, "Invalid email or password", nil)
|
respondError(w, http.StatusUnauthorized, "Invalid email or password", nil)
|
||||||
return
|
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)
|
token, err := h.AuthService.GenerateToken(user.ID, user.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -185,9 +215,10 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, models.AuthTokenResponse{
|
respondJSON(w, http.StatusOK, models.AuthTokenResponse{
|
||||||
Token: token,
|
Token: token,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
Email: user.Email,
|
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
|
// Register handles POST /auth/register
|
||||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
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 {
|
var req struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -253,18 +292,290 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := h.AuthService.GenerateToken(userID, req.Email)
|
if err := h.sendVerificationEmail(r, userID, req.Email); err != nil {
|
||||||
if err != nil {
|
respondError(w, http.StatusInternalServerError, "Failed to send verification email", strPtr(err.Error()))
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to generate token", nil)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusCreated, models.AuthTokenResponse{
|
respondJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
Token: token,
|
"email": req.Email,
|
||||||
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) {
|
func (h *AuthHandler) writeOAuthTokenResponse(w http.ResponseWriter, userID int, email string) {
|
||||||
jwt, _ := h.AuthService.GenerateToken(userID, email)
|
jwt, _ := h.AuthService.GenerateToken(userID, email)
|
||||||
refresh, _ := auth.GenerateRefreshToken()
|
refresh, _ := auth.GenerateRefreshToken()
|
||||||
@@ -278,12 +589,16 @@ func (h *AuthHandler) writeOAuthTokenResponse(w http.ResponseWriter, userID int,
|
|||||||
|
|
||||||
// ---- HTML login page ----
|
// ---- 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")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
errorHTML := ""
|
errorHTML := ""
|
||||||
if errorMsg != "" {
|
if errorMsg != "" {
|
||||||
errorHTML = fmt.Sprintf(`<div class="error">%s</div>`, 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>
|
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<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)}
|
.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{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: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}
|
.error{background:#fef2f2;color:#ef4444;padding:10px 14px;border-radius:8px;margin-bottom:16px;font-size:13px}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</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>
|
<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>
|
<button type="submit" class="btn">Sign In</button>
|
||||||
</form>
|
</form>
|
||||||
|
%s
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`, errorHTML)
|
</html>`, errorHTML, ssoHTML)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -27,6 +28,7 @@ func parseIntParam(r *http.Request, name string) (int, error) {
|
|||||||
type ReleaseHandler struct {
|
type ReleaseHandler struct {
|
||||||
DB db.Store
|
DB db.Store
|
||||||
Storage storage.Store
|
Storage storage.Store
|
||||||
|
BaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetReleases handles GET /api/v1/apps/{appId}/releases
|
// 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)
|
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
|
// CreateReleaseArtifact handles POST /api/v1/apps/{appId}/releases/{releaseId}/artifacts
|
||||||
func (h *ReleaseHandler) CreateReleaseArtifact(w http.ResponseWriter, r *http.Request) {
|
func (h *ReleaseHandler) CreateReleaseArtifact(w http.ResponseWriter, r *http.Request) {
|
||||||
appID := chi.URLParam(r, "appId")
|
appID := chi.URLParam(r, "appId")
|
||||||
@@ -224,7 +239,7 @@ func (h *ReleaseHandler) GetReleaseArtifacts(w http.ResponseWriter, r *http.Requ
|
|||||||
var result []models.ReleaseArtifact
|
var result []models.ReleaseArtifact
|
||||||
for _, a := range artifacts {
|
for _, a := range artifacts {
|
||||||
// Build a download URL for the artifact
|
// 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{
|
result = append(result, models.ReleaseArtifact{
|
||||||
ID: a.ID,
|
ID: a.ID,
|
||||||
ReleaseID: a.ReleaseID,
|
ReleaseID: a.ReleaseID,
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
authpkg "github.com/shorebird-server/internal/auth"
|
|
||||||
"github.com/shorebird-server/internal/api/middleware"
|
"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/db"
|
||||||
|
"github.com/shorebird-server/internal/email"
|
||||||
"github.com/shorebird-server/internal/storage"
|
"github.com/shorebird-server/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewRouter creates the HTTP router with all API routes.
|
// 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 := chi.NewRouter()
|
||||||
r.Use(chimw.Logger)
|
r.Use(chimw.Logger)
|
||||||
r.Use(chimw.Recoverer)
|
r.Use(chimw.Recoverer)
|
||||||
@@ -31,12 +32,12 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
authHandler := &AuthHandler{DB: database, AuthService: authService}
|
authHandler := &AuthHandler{DB: database, AuthService: authService, Mailer: mailer, BaseURL: baseURL}
|
||||||
userHandler := &UserHandler{DB: database}
|
userHandler := &UserHandler{DB: database, BaseURL: baseURL}
|
||||||
appHandler := &AppHandler{DB: database}
|
appHandler := &AppHandler{DB: database}
|
||||||
channelHandler := &ChannelHandler{DB: database}
|
channelHandler := &ChannelHandler{DB: database}
|
||||||
orgHandler := &OrganizationHandler{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}
|
patchHandler := &PatchHandler{DB: database, Storage: store}
|
||||||
deviceHandler := &DeviceHandler{DB: database, Storage: store}
|
deviceHandler := &DeviceHandler{DB: database, Storage: store}
|
||||||
diagHandler := &DiagnosticsHandler{}
|
diagHandler := &DiagnosticsHandler{}
|
||||||
@@ -44,7 +45,7 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St
|
|||||||
storageHandler := NewStorageHandler(store)
|
storageHandler := NewStorageHandler(store)
|
||||||
|
|
||||||
// Auth middleware
|
// Auth middleware
|
||||||
authMw := middleware.AuthMiddleware(authService)
|
authMw := middleware.AuthMiddleware(authService, database)
|
||||||
|
|
||||||
// --- Local storage upload/download (used when STORAGE_DRIVER=local) ---
|
// --- Local storage upload/download (used when STORAGE_DRIVER=local) ---
|
||||||
r.Route("/storage", func(r chi.Router) {
|
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.Route("/auth", func(r chi.Router) {
|
||||||
r.Get("/login", authHandler.LoginPage)
|
r.Get("/login", authHandler.LoginPage)
|
||||||
r.Post("/login", authHandler.LoginPage)
|
r.Post("/login", authHandler.LoginPage)
|
||||||
|
r.Get("/public-settings", authHandler.PublicSettings)
|
||||||
r.Post("/token", authHandler.Login)
|
r.Post("/token", authHandler.Login)
|
||||||
r.Post("/register", authHandler.Register)
|
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)
|
r.With(authMw).Post("/refresh", authHandler.Refresh)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -73,15 +82,20 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St
|
|||||||
|
|
||||||
// Users
|
// Users
|
||||||
r.Get("/users/me", userHandler.GetCurrentUser)
|
r.Get("/users/me", userHandler.GetCurrentUser)
|
||||||
|
r.Patch("/users/me/password", userHandler.UpdatePassword)
|
||||||
r.Post("/users", userHandler.CreateUser)
|
r.Post("/users", userHandler.CreateUser)
|
||||||
|
|
||||||
// Organizations
|
// Organizations
|
||||||
r.Get("/organizations", orgHandler.GetOrganizations)
|
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
|
// Apps
|
||||||
r.Get("/apps", appHandler.GetApps)
|
r.Get("/apps", appHandler.GetApps)
|
||||||
r.Post("/apps", appHandler.CreateApp)
|
r.Post("/apps", appHandler.CreateApp)
|
||||||
r.Delete("/apps/{appId}", appHandler.DeleteApp)
|
r.Delete("/apps/{appId}", appHandler.DeleteApp)
|
||||||
|
r.Patch("/apps/{appId}/transfer", appHandler.TransferApp)
|
||||||
|
|
||||||
// Channels
|
// Channels
|
||||||
r.Get("/apps/{appId}/channels", channelHandler.GetChannels)
|
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.Get("/apps/{appId}/releases", releaseHandler.GetReleases)
|
||||||
r.Post("/apps/{appId}/releases", releaseHandler.CreateRelease)
|
r.Post("/apps/{appId}/releases", releaseHandler.CreateRelease)
|
||||||
r.Patch("/apps/{appId}/releases/{releaseId}", releaseHandler.UpdateRelease)
|
r.Patch("/apps/{appId}/releases/{releaseId}", releaseHandler.UpdateRelease)
|
||||||
|
r.Delete("/apps/{appId}/releases/{releaseId}", releaseHandler.DeleteRelease)
|
||||||
|
|
||||||
// Release artifacts
|
// Release artifacts
|
||||||
r.Post("/apps/{appId}/releases/{releaseId}/artifacts", releaseHandler.CreateReleaseArtifact)
|
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
|
// Admin: user listing
|
||||||
r.Get("/admin/users", adminHandler.ListUsers)
|
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
|
// Admin: targeted device patching
|
||||||
r.Post("/admin/patches/{patchId}/target-devices", adminHandler.AddTargetDevice)
|
r.Post("/admin/patches/{patchId}/target-devices", adminHandler.AddTargetDevice)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/shorebird-server/internal/storage"
|
"github.com/shorebird-server/internal/storage"
|
||||||
@@ -29,7 +30,24 @@ func (h *StorageHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
ct = "application/octet-stream"
|
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)
|
respondError(w, http.StatusInternalServerError, "Upload failed: "+err.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shorebird-server/internal/auth"
|
"github.com/shorebird-server/internal/auth"
|
||||||
|
"github.com/shorebird-server/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
@@ -16,7 +17,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// AuthMiddleware validates JWT tokens and injects claims into the request context.
|
// 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 func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
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)
|
http.Error(w, `{"message":"Invalid or expired token"}`, http.StatusUnauthorized)
|
||||||
return
|
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)
|
ctx := context.WithValue(r.Context(), ClaimsKey, claims)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type Config struct {
|
|||||||
DB DBConfig
|
DB DBConfig
|
||||||
Storage StorageConfig
|
Storage StorageConfig
|
||||||
Auth AuthConfig
|
Auth AuthConfig
|
||||||
|
Email EmailConfig
|
||||||
|
Admin AdminConfig
|
||||||
Redis RedisConfig
|
Redis RedisConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +58,22 @@ type AuthConfig struct {
|
|||||||
TokenDuration time.Duration
|
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.
|
// RedisConfig holds Redis connection configuration.
|
||||||
type RedisConfig struct {
|
type RedisConfig struct {
|
||||||
Addr string
|
Addr string
|
||||||
@@ -95,6 +113,18 @@ func Load() *Config {
|
|||||||
JWTSecret: envOrDefault("JWT_SECRET", "change-me-in-production-use-a-long-random-string"),
|
JWTSecret: envOrDefault("JWT_SECRET", "change-me-in-production-use-a-long-random-string"),
|
||||||
TokenDuration: 24 * time.Hour,
|
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{
|
Redis: RedisConfig{
|
||||||
Addr: envOrDefault("REDIS_ADDR", "localhost:6379"),
|
Addr: envOrDefault("REDIS_ADDR", "localhost:6379"),
|
||||||
Password: envOrDefault("REDIS_PASSWORD", ""),
|
Password: envOrDefault("REDIS_PASSWORD", ""),
|
||||||
|
|||||||
+155
-14
@@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -39,8 +40,6 @@ func (db *PgStore) Close() {
|
|||||||
db.pool.Close()
|
db.pool.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Exec is a convenience wrapper for pool.Exec.
|
// Exec is a convenience wrapper for pool.Exec.
|
||||||
func (db *PgStore) exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
|
func (db *PgStore) exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
|
||||||
return db.pool.Exec(ctx, sql, args...)
|
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) {
|
func (db *PgStore) CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) {
|
||||||
var id int
|
var id int
|
||||||
err := db.queryRow(ctx,
|
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,
|
email, name, passwordHash,
|
||||||
).Scan(&id)
|
).Scan(&id)
|
||||||
return id, err
|
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.
|
// GetUserByEmail retrieves a user by email.
|
||||||
func (db *PgStore) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) {
|
func (db *PgStore) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) {
|
||||||
row := &UserRow{}
|
row := &UserRow{}
|
||||||
err := db.queryRow(ctx,
|
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,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (db *PgStore) GetUserByID(ctx context.Context, id int) (*UserRow, error) {
|
||||||
row := &UserRow{}
|
row := &UserRow{}
|
||||||
err := db.queryRow(ctx,
|
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,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return row, nil
|
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 ---
|
// --- Organization queries ---
|
||||||
|
|
||||||
// CreateOrganization creates a new organization.
|
// CreateOrganization creates a new organization.
|
||||||
@@ -130,16 +177,43 @@ func (db *PgStore) GetOrganizationsByUserID(ctx context.Context, userID int) ([]
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// AddUserToOrganization adds a user to an organization.
|
// AddUserToOrganization adds a user to an organization.
|
||||||
func (db *PgStore) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error {
|
func (db *PgStore) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error {
|
||||||
_, err := db.exec(ctx,
|
_, err := db.exec(ctx,
|
||||||
`INSERT INTO organization_memberships (user_id, organization_id, role)
|
`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)
|
userID, orgID, role)
|
||||||
return err
|
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 ---
|
// --- App queries ---
|
||||||
|
|
||||||
// CreateApp creates a new app and returns it.
|
// 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
|
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.
|
// GetAppsByUserID returns all apps the user has access to via org memberships.
|
||||||
func (db *PgStore) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) {
|
func (db *PgStore) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) {
|
||||||
rows, err := db.query(ctx,
|
rows, err := db.query(ctx,
|
||||||
@@ -229,7 +308,6 @@ func (db *PgStore) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, e
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Channel queries ---
|
// --- Channel queries ---
|
||||||
|
|
||||||
// CreateChannel creates a new channel for an app.
|
// 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
|
return row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Release queries ---
|
// --- Release queries ---
|
||||||
|
|
||||||
// CreateRelease creates a new release.
|
// CreateRelease creates a new release.
|
||||||
@@ -378,6 +455,10 @@ func (db *PgStore) GetReleasePlatformStatuses(ctx context.Context, releaseID int
|
|||||||
return result, nil
|
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 ---
|
// --- Release artifact queries ---
|
||||||
|
|
||||||
@@ -430,7 +511,6 @@ func (db *PgStore) GetReleaseArtifacts(ctx context.Context, releaseID int, arch,
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Patch queries ---
|
// --- Patch queries ---
|
||||||
|
|
||||||
// GetNextPatchNumber returns the next patch number for a release.
|
// 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
|
return row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Patch events ---
|
// --- Patch events ---
|
||||||
|
|
||||||
// InsertPatchEvent records a patch lifecycle event.
|
// InsertPatchEvent records a patch lifecycle event.
|
||||||
@@ -649,6 +728,7 @@ func (db *PgStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
|
|||||||
rows, err := db.query(ctx,
|
rows, err := db.query(ctx,
|
||||||
`SELECT u.id, u.email, u.name, u.created_at,
|
`SELECT u.id, u.email, u.name, u.created_at,
|
||||||
COALESCE(string_agg(DISTINCT om.role, ', '), '') as roles
|
COALESCE(string_agg(DISTINCT om.role, ', '), '') as roles
|
||||||
|
, u.email_verified, u.is_admin, u.must_change_password
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN organization_memberships om ON u.id = om.user_id
|
LEFT JOIN organization_memberships om ON u.id = om.user_id
|
||||||
GROUP BY u.id
|
GROUP BY u.id
|
||||||
@@ -661,7 +741,7 @@ func (db *PgStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
|
|||||||
var result []UserListRow
|
var result []UserListRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r UserListRow
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
result = append(result, r)
|
result = append(result, r)
|
||||||
@@ -669,3 +749,64 @@ func (db *PgStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
|
|||||||
return result, nil
|
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,
|
email TEXT NOT NULL UNIQUE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
password_hash 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()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -28,6 +32,33 @@ CREATE TABLE organization_memberships (
|
|||||||
UNIQUE(user_id, organization_id)
|
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
|
-- Apps table
|
||||||
CREATE TABLE apps (
|
CREATE TABLE apps (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
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_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_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_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
|
-- +goose StatementEnd
|
||||||
|
|
||||||
@@ -178,6 +210,8 @@ DROP TABLE IF EXISTS release_platform_statuses CASCADE;
|
|||||||
DROP TABLE IF EXISTS releases CASCADE;
|
DROP TABLE IF EXISTS releases CASCADE;
|
||||||
DROP TABLE IF EXISTS channels CASCADE;
|
DROP TABLE IF EXISTS channels CASCADE;
|
||||||
DROP TABLE IF EXISTS apps 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 organization_memberships CASCADE;
|
||||||
DROP TABLE IF EXISTS users CASCADE;
|
DROP TABLE IF EXISTS users CASCADE;
|
||||||
DROP TABLE IF EXISTS organizations CASCADE;
|
DROP TABLE IF EXISTS organizations CASCADE;
|
||||||
|
|||||||
+305
-54
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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) {
|
func (s *SqliteStore) CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) {
|
||||||
var id int
|
var id int
|
||||||
err := s.db.QueryRowContext(ctx,
|
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)
|
email, name, passwordHash).Scan(&id)
|
||||||
return id, err
|
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) {
|
func (s *SqliteStore) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) {
|
||||||
r := &UserRow{}
|
r := &UserRow{}
|
||||||
err := s.db.QueryRowContext(ctx,
|
err := s.db.QueryRowContext(ctx,
|
||||||
`SELECT id, email, name, password_hash, created_at FROM users WHERE email = ?`, email).
|
`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.CreatedAt)
|
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 }
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) GetUserByID(ctx context.Context, id int) (*UserRow, error) {
|
func (s *SqliteStore) GetUserByID(ctx context.Context, id int) (*UserRow, error) {
|
||||||
r := &UserRow{}
|
r := &UserRow{}
|
||||||
err := s.db.QueryRowContext(ctx,
|
err := s.db.QueryRowContext(ctx,
|
||||||
`SELECT id, email, name, password_hash, created_at FROM users WHERE id = ?`, id).
|
`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.CreatedAt)
|
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 }
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
|
func (s *SqliteStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
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`)
|
`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 }
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var result []UserListRow
|
var result []UserListRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r UserListRow
|
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)
|
result = append(result, r)
|
||||||
}
|
}
|
||||||
return result, nil
|
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 ---
|
// --- Organizations ---
|
||||||
func (s *SqliteStore) CreateOrganization(ctx context.Context, name, orgType string) (int, error) {
|
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) {
|
func (s *SqliteStore) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
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)
|
`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()
|
defer rows.Close()
|
||||||
var result []OrgMembershipRow
|
var result []OrgMembershipRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r OrgMembershipRow
|
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)
|
result = append(result, r)
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error {
|
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
|
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 ---
|
// --- Apps ---
|
||||||
// rfcNow returns the current UTC time in RFC3339Nano format for SQLite storage.
|
// 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()
|
id := uuid.New().String()
|
||||||
now := rfcNow()
|
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)
|
_, 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)
|
uid, _ := uuid.Parse(id)
|
||||||
t := time.Now().UTC()
|
t := time.Now().UTC()
|
||||||
return &AppRow{ID: uid, OrganizationID: orgID, DisplayName: displayName, CreatedAt: ShorebirdTime{Time: t}, UpdatedAt: ShorebirdTime{Time: t}}, nil
|
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) {
|
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)
|
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()
|
defer rows.Close()
|
||||||
return scanApps(rows)
|
return scanApps(rows)
|
||||||
}
|
}
|
||||||
@@ -127,22 +252,32 @@ func (s *SqliteStore) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow,
|
|||||||
var idStr string
|
var idStr string
|
||||||
err := s.db.QueryRowContext(ctx, `SELECT id, organization_id, display_name, created_at, updated_at FROM apps WHERE id = ?`, appID.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)
|
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)
|
r.ID, _ = uuid.Parse(idStr)
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) GetAppByIDString(ctx context.Context, appID string) (*AppRow, error) {
|
func (s *SqliteStore) GetAppByIDString(ctx context.Context, appID string) (*AppRow, error) {
|
||||||
id, err := uuid.Parse(appID)
|
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)
|
return s.GetAppByID(ctx, id)
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) DeleteApp(ctx context.Context, appID uuid.UUID) error {
|
func (s *SqliteStore) DeleteApp(ctx context.Context, appID uuid.UUID) error {
|
||||||
_, err := s.db.ExecContext(ctx, `DELETE FROM apps WHERE id = ?`, appID.String())
|
_, err := s.db.ExecContext(ctx, `DELETE FROM apps WHERE id = ?`, appID.String())
|
||||||
return err
|
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) {
|
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)
|
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()
|
defer rows.Close()
|
||||||
return scanApps(rows)
|
return scanApps(rows)
|
||||||
}
|
}
|
||||||
@@ -151,7 +286,9 @@ func scanApps(rows *sql.Rows) ([]AppRow, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r AppRow
|
var r AppRow
|
||||||
var idStr string
|
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)
|
r.ID, _ = uuid.Parse(idStr)
|
||||||
result = append(result, r)
|
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) {
|
func (s *SqliteStore) CreateChannel(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) {
|
||||||
r := &ChannelRow{AppID: appID, Name: name}
|
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)
|
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
|
return r, nil
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error) {
|
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())
|
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()
|
defer rows.Close()
|
||||||
var result []ChannelRow
|
var result []ChannelRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r ChannelRow; var aid string
|
var r ChannelRow
|
||||||
if err := rows.Scan(&r.ID, &aid, &r.Name, &r.CreatedAt); err != nil { return nil, err }
|
var aid string
|
||||||
r.AppID, _ = uuid.Parse(aid); result = append(result, r)
|
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
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -181,7 +326,9 @@ func (s *SqliteStore) GetChannelByAppIDAndName(ctx context.Context, appID uuid.U
|
|||||||
r := &ChannelRow{}
|
r := &ChannelRow{}
|
||||||
var aid string
|
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)
|
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)
|
r.AppID, _ = uuid.Parse(aid)
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
@@ -189,16 +336,26 @@ func (s *SqliteStore) GetChannelByAppIDAndName(ctx context.Context, appID uuid.U
|
|||||||
// --- Releases ---
|
// --- Releases ---
|
||||||
func (s *SqliteStore) CreateRelease(ctx context.Context, appID uuid.UUID, version, flutterRevision string, flutterVersion, displayName *string) (*ReleaseRow, error) {
|
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}
|
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)
|
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
|
return r, nil
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error) {
|
func (s *SqliteStore) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error) {
|
||||||
r := &ReleaseRow{}
|
r := &ReleaseRow{}
|
||||||
var aid string
|
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)
|
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)
|
r.AppID, _ = uuid.Parse(aid)
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
@@ -206,58 +363,93 @@ func (s *SqliteStore) GetReleaseByAppIDAndVersion(ctx context.Context, appID uui
|
|||||||
r := &ReleaseRow{}
|
r := &ReleaseRow{}
|
||||||
var aid string
|
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)
|
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)
|
r.AppID, _ = uuid.Parse(aid)
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error) {
|
func (s *SqliteStore) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error) {
|
||||||
_ = sideloadableOnly
|
_ = 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())
|
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()
|
defer rows.Close()
|
||||||
var result []ReleaseRow
|
var result []ReleaseRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r ReleaseRow; var aid string
|
var r ReleaseRow
|
||||||
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 }
|
var aid string
|
||||||
r.AppID, _ = uuid.Parse(aid); result = append(result, r)
|
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
|
return result, nil
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error {
|
func (s *SqliteStore) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error {
|
||||||
metaJSON := "{}"
|
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())
|
_, 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
|
return err
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) {
|
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)
|
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()
|
defer rows.Close()
|
||||||
result := make(map[string]string)
|
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
|
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 ---
|
// --- Release Artifacts ---
|
||||||
func (s *SqliteStore) CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error) {
|
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}
|
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)
|
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
|
return r, nil
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, platform *string) ([]ArtifactRow, error) {
|
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 = ?`
|
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}
|
args := []interface{}{releaseID}
|
||||||
if arch != nil { query += " AND arch = ?"; args = append(args, *arch) }
|
if arch != nil {
|
||||||
if platform != nil { query += " AND platform = ?"; args = append(args, *platform) }
|
query += " AND arch = ?"
|
||||||
|
args = append(args, *arch)
|
||||||
|
}
|
||||||
|
if platform != nil {
|
||||||
|
query += " AND platform = ?"
|
||||||
|
args = append(args, *platform)
|
||||||
|
}
|
||||||
query += " ORDER BY arch"
|
query += " ORDER BY arch"
|
||||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil { return nil, err }
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var result []ArtifactRow
|
var result []ArtifactRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r ArtifactRow
|
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)
|
result = append(result, r)
|
||||||
}
|
}
|
||||||
return result, nil
|
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) {
|
func (s *SqliteStore) CreatePatch(ctx context.Context, releaseID int, number int, notes *string) (*PatchRow, error) {
|
||||||
r := &PatchRow{ReleaseID: releaseID, Number: number, Notes: notes}
|
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)
|
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
|
return r, nil
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) {
|
func (s *SqliteStore) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) {
|
||||||
r := &PatchRow{}
|
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)
|
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
|
return r, nil
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error) {
|
func (s *SqliteStore) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error) {
|
||||||
r := &PatchRow{}
|
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)
|
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
|
return r, nil
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) {
|
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)
|
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()
|
defer rows.Close()
|
||||||
var result []PatchWithChannelRow
|
var result []PatchWithChannelRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r PatchWithChannelRow
|
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)
|
result = append(result, r)
|
||||||
}
|
}
|
||||||
return result, nil
|
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) {
|
func (s *SqliteStore) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int) (*PatchWithArtifactRow, error) {
|
||||||
r := &PatchWithArtifactRow{}
|
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)
|
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
|
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) {
|
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}
|
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)
|
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
|
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) {
|
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)
|
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()
|
defer rows.Close()
|
||||||
var result []int
|
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
|
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) {
|
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)
|
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()
|
defer rows.Close()
|
||||||
var result []string
|
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
|
return result, nil
|
||||||
}
|
}
|
||||||
func (s *SqliteStore) HasTargetDevices(ctx context.Context, patchID int) (bool, error) {
|
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 {
|
func (s *SqliteStore) migrate(ctx context.Context) error {
|
||||||
_, err := s.db.ExecContext(ctx, `
|
_, 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 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 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 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 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')));
|
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_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_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_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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-10
@@ -75,14 +75,28 @@ type Store interface {
|
|||||||
|
|
||||||
// --- Users ---
|
// --- Users ---
|
||||||
CreateUser(ctx context.Context, email, name, passwordHash string) (int, error)
|
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)
|
GetUserByEmail(ctx context.Context, email string) (*UserRow, error)
|
||||||
GetUserByID(ctx context.Context, id int) (*UserRow, error)
|
GetUserByID(ctx context.Context, id int) (*UserRow, error)
|
||||||
ListAllUsers(ctx context.Context) ([]UserListRow, 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 ---
|
// --- Organizations ---
|
||||||
CreateOrganization(ctx context.Context, name, orgType string) (int, error)
|
CreateOrganization(ctx context.Context, name, orgType string) (int, error)
|
||||||
GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error)
|
GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error)
|
||||||
AddUserToOrganization(ctx context.Context, userID, orgID int, role string) 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 ---
|
// --- Apps ---
|
||||||
CreateApp(ctx context.Context, orgID int, displayName string) (*AppRow, error)
|
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)
|
GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error)
|
||||||
GetAppByIDString(ctx context.Context, appID string) (*AppRow, error)
|
GetAppByIDString(ctx context.Context, appID string) (*AppRow, error)
|
||||||
DeleteApp(ctx context.Context, appID uuid.UUID) 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)
|
GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error)
|
||||||
|
|
||||||
// --- Channels ---
|
// --- Channels ---
|
||||||
@@ -104,6 +119,7 @@ type Store interface {
|
|||||||
GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error)
|
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
|
UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error
|
||||||
GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error)
|
GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error)
|
||||||
|
DeleteRelease(ctx context.Context, releaseID int) error
|
||||||
|
|
||||||
// --- Release Artifacts ---
|
// --- Release Artifacts ---
|
||||||
CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error)
|
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.
|
// UserRow represents a user row from the database.
|
||||||
type UserRow struct {
|
type UserRow struct {
|
||||||
ID int
|
ID int
|
||||||
Email string
|
Email string
|
||||||
Name string
|
Name string
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
CreatedAt ShorebirdTime
|
EmailVerified bool
|
||||||
|
IsAdmin bool
|
||||||
|
MustChangePassword bool
|
||||||
|
AuthProvider string
|
||||||
|
CreatedAt ShorebirdTime
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrgMembershipRow represents an organization membership query result.
|
// OrgMembershipRow represents an organization membership query result.
|
||||||
@@ -154,6 +174,18 @@ type OrgMembershipRow struct {
|
|||||||
Role string
|
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.
|
// AppRow represents an app row.
|
||||||
type AppRow struct {
|
type AppRow struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
@@ -246,9 +278,22 @@ type PatchArtifactRow struct {
|
|||||||
|
|
||||||
// UserListRow is used by the admin user listing endpoint.
|
// UserListRow is used by the admin user listing endpoint.
|
||||||
type UserListRow struct {
|
type UserListRow struct {
|
||||||
ID int
|
ID int
|
||||||
Email string
|
Email string
|
||||||
Name string
|
Name string
|
||||||
CreatedAt ShorebirdTime
|
CreatedAt ShorebirdTime
|
||||||
Role string
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -17,16 +17,17 @@ type App struct {
|
|||||||
|
|
||||||
// AppMetadata is the full app info returned by GET /apps.
|
// AppMetadata is the full app info returned by GET /apps.
|
||||||
type AppMetadata struct {
|
type AppMetadata struct {
|
||||||
AppID string `json:"app_id"`
|
AppID string `json:"app_id"`
|
||||||
DisplayName string `json:"display_name"`
|
OrganizationID int `json:"organization_id,omitempty"`
|
||||||
LatestReleaseVersion *string `json:"latest_release_version"`
|
DisplayName string `json:"display_name"`
|
||||||
LatestPatchNumber *int `json:"latest_patch_number"`
|
LatestReleaseVersion *string `json:"latest_release_version"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
LatestPatchNumber *int `json:"latest_patch_number"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Platforms []string `json:"platforms"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
LatestReleases map[string]LatestRelease `json:"latest_releases"`
|
Platforms []string `json:"platforms"`
|
||||||
PendingReleases map[string]PendingRelease `json:"pending_releases"`
|
LatestReleases map[string]LatestRelease `json:"latest_releases"`
|
||||||
IconURL *string `json:"icon_url"`
|
PendingReleases map[string]PendingRelease `json:"pending_releases"`
|
||||||
|
IconURL *string `json:"icon_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LatestRelease represents the latest analyzed release per platform.
|
// LatestRelease represents the latest analyzed release per platform.
|
||||||
@@ -51,16 +52,16 @@ type Channel struct {
|
|||||||
|
|
||||||
// Release represents a release of an application.
|
// Release represents a release of an application.
|
||||||
type Release struct {
|
type Release struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
AppID uuid.UUID `json:"app_id"`
|
AppID uuid.UUID `json:"app_id"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
FlutterRevision string `json:"flutter_revision"`
|
FlutterRevision string `json:"flutter_revision"`
|
||||||
FlutterVersion *string `json:"flutter_version"`
|
FlutterVersion *string `json:"flutter_version"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Notes *string `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
PlatformStatuses map[string]string `json:"platform_statuses"`
|
PlatformStatuses map[string]string `json:"platform_statuses"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReleaseStatus values.
|
// ReleaseStatus values.
|
||||||
@@ -72,11 +73,11 @@ const (
|
|||||||
|
|
||||||
// ReleasePlatform values.
|
// ReleasePlatform values.
|
||||||
const (
|
const (
|
||||||
PlatformAndroid = "android"
|
PlatformAndroid = "android"
|
||||||
PlatformIOS = "ios"
|
PlatformIOS = "ios"
|
||||||
PlatformMacOS = "macos"
|
PlatformMacOS = "macos"
|
||||||
PlatformWindows = "windows"
|
PlatformWindows = "windows"
|
||||||
PlatformLinux = "linux"
|
PlatformLinux = "linux"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReleaseArtifact represents metadata about a release artifact.
|
// ReleaseArtifact represents metadata about a release artifact.
|
||||||
@@ -95,18 +96,18 @@ type ReleaseArtifact struct {
|
|||||||
|
|
||||||
// Patch represents a patch (hotfix update) for a release.
|
// Patch represents a patch (hotfix update) for a release.
|
||||||
type Patch struct {
|
type Patch struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Notes *string `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReleasePatch is the join between releases and patches.
|
// ReleasePatch is the join between releases and patches.
|
||||||
type ReleasePatch struct {
|
type ReleasePatch struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
ReleaseID int `json:"release_id"`
|
ReleaseID int `json:"release_id"`
|
||||||
PatchID int `json:"patch_id"`
|
PatchID int `json:"patch_id"`
|
||||||
PatchNumber int `json:"patch_number"`
|
PatchNumber int `json:"patch_number"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PatchArtifact represents metadata about a patch artifact.
|
// PatchArtifact represents metadata about a patch artifact.
|
||||||
@@ -122,22 +123,22 @@ type PatchArtifact struct {
|
|||||||
|
|
||||||
// PatchCheckRequest is the POST /patches/check request body.
|
// PatchCheckRequest is the POST /patches/check request body.
|
||||||
type PatchCheckRequest struct {
|
type PatchCheckRequest struct {
|
||||||
ReleaseVersion string `json:"release_version"`
|
ReleaseVersion string `json:"release_version"`
|
||||||
PatchNumber *int `json:"patch_number"`
|
PatchNumber *int `json:"patch_number"`
|
||||||
PatchHash *string `json:"patch_hash"`
|
PatchHash *string `json:"patch_hash"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
AppID string `json:"app_id"`
|
AppID string `json:"app_id"`
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
ClientID *string `json:"client_id"`
|
ClientID *string `json:"client_id"`
|
||||||
CurrentPatchNumber *int `json:"current_patch_number"`
|
CurrentPatchNumber *int `json:"current_patch_number"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PatchCheckResponse is the POST /patches/check response body.
|
// PatchCheckResponse is the POST /patches/check response body.
|
||||||
type PatchCheckResponse struct {
|
type PatchCheckResponse struct {
|
||||||
PatchAvailable bool `json:"patch_available"`
|
PatchAvailable bool `json:"patch_available"`
|
||||||
Patch *PatchCheckMetadata `json:"patch"`
|
Patch *PatchCheckMetadata `json:"patch"`
|
||||||
RolledBackPatchNumbers []int `json:"rolled_back_patch_numbers"`
|
RolledBackPatchNumbers []int `json:"rolled_back_patch_numbers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PatchCheckMetadata is the patch metadata returned in patch check.
|
// PatchCheckMetadata is the patch metadata returned in patch check.
|
||||||
@@ -283,9 +284,9 @@ type CreatePatchArtifactResponse struct {
|
|||||||
|
|
||||||
// UpdateReleaseRequest is the PATCH /releases/{id} request body.
|
// UpdateReleaseRequest is the PATCH /releases/{id} request body.
|
||||||
type UpdateReleaseRequest struct {
|
type UpdateReleaseRequest struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
Metadata map[string]interface{} `json:"metadata"`
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromotePatchRequest is the POST /patches/promote request body.
|
// PromotePatchRequest is the POST /patches/promote request body.
|
||||||
@@ -318,7 +319,8 @@ type AuthTokenRequest struct {
|
|||||||
|
|
||||||
// AuthTokenResponse is the login response.
|
// AuthTokenResponse is the login response.
|
||||||
type AuthTokenResponse struct {
|
type AuthTokenResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
MustChangePassword bool `json:"must_change_password,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-1
@@ -31,8 +31,10 @@
|
|||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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);">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,6 +85,9 @@
|
|||||||
<a href="#users" data-page="users">
|
<a href="#users" data-page="users">
|
||||||
<span class="nav-icon">👥</span> Users
|
<span class="nav-icon">👥</span> Users
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#organizations" data-page="organizations">
|
||||||
|
<span class="nav-icon">🏢</span> Organizations
|
||||||
|
</a>
|
||||||
<a href="#settings" data-page="settings">
|
<a href="#settings" data-page="settings">
|
||||||
<span class="nav-icon">⚙️</span> Settings
|
<span class="nav-icon">⚙️</span> Settings
|
||||||
</a>
|
</a>
|
||||||
@@ -148,16 +153,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
SETTINGS PAGE
|
||||||
================================================ -->
|
================================================ -->
|
||||||
<section id="page-settings" class="page-section">
|
<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">
|
||||||
<div class="card-header"><h3>Server Configuration</h3></div>
|
<div class="card-header"><h3>Server Configuration</h3></div>
|
||||||
<div class="card-body" style="padding:24px;">
|
<div class="card-body" style="padding:24px;">
|
||||||
<div class="info-grid" id="serverInfo"></div>
|
<div class="info-grid" id="serverInfo"></div>
|
||||||
</div>
|
</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" style="margin-top:20px;">
|
||||||
<div class="card-header"><h3>API Token</h3></div>
|
<div class="card-header"><h3>API Token</h3></div>
|
||||||
<div class="card-body" style="padding:24px;">
|
<div class="card-body" style="padding:24px;">
|
||||||
@@ -190,6 +284,10 @@
|
|||||||
<label for="appDisplayName">Display Name</label>
|
<label for="appDisplayName">Display Name</label>
|
||||||
<input type="text" id="appDisplayName" placeholder="My Flutter App" required>
|
<input type="text" id="appDisplayName" placeholder="My Flutter App" required>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="appOrganizationId">Organization</label>
|
||||||
|
<select id="appOrganizationId"></select>
|
||||||
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn btn-outline" onclick="closeModal('createAppOverlay')">Cancel</button>
|
<button type="button" class="btn btn-outline" onclick="closeModal('createAppOverlay')">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary">Create App</button>
|
<button type="submit" class="btn btn-primary">Create App</button>
|
||||||
@@ -235,6 +333,74 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script src="/web/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+544
-29
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Shorebird Self-Hosted Dashboard — Complete SPA Application
|
* Shorebird Self-Hosted Dashboard - Complete SPA Application
|
||||||
* Vanilla JS, no framework dependencies.
|
* Vanilla JS, no framework dependencies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -9,6 +9,9 @@ const state = {
|
|||||||
user: null,
|
user: null,
|
||||||
apps: [],
|
apps: [],
|
||||||
users: [],
|
users: [],
|
||||||
|
organizations: [],
|
||||||
|
currentApp: null,
|
||||||
|
currentRelease: null,
|
||||||
confirmCallback: null,
|
confirmCallback: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,12 +56,13 @@ function navigate(page) {
|
|||||||
const link = document.querySelector(`#sidebarNav a[data-page="${page}"]`);
|
const link = document.querySelector(`#sidebarNav a[data-page="${page}"]`);
|
||||||
if (link) link.classList.add('active');
|
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;
|
document.getElementById('pageTitle').textContent = titles[page] || page;
|
||||||
|
|
||||||
if (page === 'dashboard') loadDashboard();
|
if (page === 'dashboard') loadDashboard();
|
||||||
if (page === 'apps') loadApps();
|
if (page === 'apps') loadApps();
|
||||||
if (page === 'users') loadUsers();
|
if (page === 'users') loadUsers();
|
||||||
|
if (page === 'organizations') loadOrganizations();
|
||||||
if (page === 'settings') loadSettings();
|
if (page === 'settings') loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +95,27 @@ function executeConfirm() {
|
|||||||
async function login(email, password) {
|
async function login(email, password) {
|
||||||
const data = await api.request('POST', '/auth/token', { email, password });
|
const data = await api.request('POST', '/auth/token', { email, password });
|
||||||
state.token = data.token;
|
state.token = data.token;
|
||||||
|
state.mustChangePassword = data.must_change_password;
|
||||||
localStorage.setItem('shorebird_token', data.token);
|
localStorage.setItem('shorebird_token', data.token);
|
||||||
await loadCurrentUser();
|
await loadCurrentUser();
|
||||||
showApp();
|
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() {
|
function logout() {
|
||||||
@@ -109,9 +130,9 @@ function logout() {
|
|||||||
async function loadCurrentUser() {
|
async function loadCurrentUser() {
|
||||||
try {
|
try {
|
||||||
state.user = await api.get('/users/me');
|
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) {
|
} catch (e) {
|
||||||
// User endpoint might not be available
|
|
||||||
state.user = { email: 'admin' };
|
state.user = { email: 'admin' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,14 +143,9 @@ function showApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function register(name, email, password) {
|
async function register(name, email, password) {
|
||||||
const data = await api.request('POST', '/auth/register', { name, email, password });
|
await api.request('POST', '/auth/register', { name, email, password });
|
||||||
state.token = data.token;
|
|
||||||
localStorage.setItem('shorebird_token', data.token);
|
|
||||||
await loadCurrentUser();
|
|
||||||
closeModal('registerOverlay');
|
closeModal('registerOverlay');
|
||||||
showApp();
|
showToast('Account created. Check your email to verify before signing in.', 'success');
|
||||||
navigate('dashboard');
|
|
||||||
showToast('Account created successfully', 'success');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Dashboard ----
|
// ---- Dashboard ----
|
||||||
@@ -138,7 +154,7 @@ async function loadDashboard() {
|
|||||||
const appsResp = await api.get('/apps');
|
const appsResp = await api.get('/apps');
|
||||||
state.apps = appsResp.apps || [];
|
state.apps = appsResp.apps || [];
|
||||||
document.getElementById('statApps').textContent = state.apps.length;
|
document.getElementById('statApps').textContent = state.apps.length;
|
||||||
document.getElementById('statPatches').textContent = '—';
|
document.getElementById('statPatches').textContent = '-';
|
||||||
|
|
||||||
// Try to get users
|
// Try to get users
|
||||||
try {
|
try {
|
||||||
@@ -146,7 +162,7 @@ async function loadDashboard() {
|
|||||||
state.users = usersResp.users || [];
|
state.users = usersResp.users || [];
|
||||||
document.getElementById('statUsers').textContent = state.users.length;
|
document.getElementById('statUsers').textContent = state.users.length;
|
||||||
} catch {
|
} catch {
|
||||||
document.getElementById('statUsers').textContent = '—';
|
document.getElementById('statUsers').textContent = '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get organizations
|
// Try to get organizations
|
||||||
@@ -155,13 +171,13 @@ async function loadDashboard() {
|
|||||||
const orgs = orgsResp.organizations || [];
|
const orgs = orgsResp.organizations || [];
|
||||||
document.getElementById('statOrganizations').textContent = orgs.length;
|
document.getElementById('statOrganizations').textContent = orgs.length;
|
||||||
} catch {
|
} catch {
|
||||||
document.getElementById('statOrganizations').textContent = '—';
|
document.getElementById('statOrganizations').textContent = '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent apps table
|
// Recent apps table
|
||||||
const tableDiv = document.getElementById('recentAppsTable');
|
const tableDiv = document.getElementById('recentAppsTable');
|
||||||
if (state.apps.length === 0) {
|
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 {
|
} else {
|
||||||
const recent = state.apps.slice(0, 5);
|
const recent = state.apps.slice(0, 5);
|
||||||
tableDiv.innerHTML = renderTable(
|
tableDiv.innerHTML = renderTable(
|
||||||
@@ -186,7 +202,7 @@ async function loadApps() {
|
|||||||
const tableDiv = document.getElementById('appsTable');
|
const tableDiv = document.getElementById('appsTable');
|
||||||
|
|
||||||
if (state.apps.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +212,8 @@ async function loadApps() {
|
|||||||
a.display_name,
|
a.display_name,
|
||||||
`<code class="code">${a.app_id}</code>`,
|
`<code class="code">${a.app_id}</code>`,
|
||||||
new Date(a.created_at).toLocaleDateString(),
|
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="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">🗑️</button>`,
|
<button class="btn btn-ghost" onclick="confirmDeleteApp('${a.app_id}','${escapeHtml(a.display_name)}')" title="Delete App">Delete</button>`,
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -205,9 +221,9 @@ async function loadApps() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createApp(displayName) {
|
async function createApp(displayName, organizationId) {
|
||||||
try {
|
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');
|
closeModal('createAppOverlay');
|
||||||
showToast(`App "${displayName}" created`, 'success');
|
showToast(`App "${displayName}" created`, 'success');
|
||||||
loadApps();
|
loadApps();
|
||||||
@@ -232,6 +248,16 @@ function confirmDeleteApp(appId, name) {
|
|||||||
|
|
||||||
function openCreateAppModal() {
|
function openCreateAppModal() {
|
||||||
document.getElementById('appDisplayName').value = '';
|
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');
|
openModal('createAppOverlay');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +269,7 @@ async function loadUsers() {
|
|||||||
const tableDiv = document.getElementById('usersTable');
|
const tableDiv = document.getElementById('usersTable');
|
||||||
|
|
||||||
if (state.users.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,15 +278,15 @@ async function loadUsers() {
|
|||||||
state.users.map(u => [
|
state.users.map(u => [
|
||||||
u.id,
|
u.id,
|
||||||
u.email,
|
u.email,
|
||||||
u.name || '—',
|
u.name || '-',
|
||||||
u.role ? `<span class="badge badge-info">${u.role}</span>` : `<span class="badge">member</span>`,
|
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) {
|
} catch (e) {
|
||||||
// If admin/users endpoint is not available, show a message
|
// If admin/users endpoint is not available, show a message
|
||||||
document.getElementById('usersTable').innerHTML =
|
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 ----
|
// ---- Event Listeners ----
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadPublicSettings();
|
||||||
|
|
||||||
// Login form
|
// Login form
|
||||||
document.getElementById('loginForm').addEventListener('submit', async e => {
|
document.getElementById('loginForm').addEventListener('submit', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -397,6 +591,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
openModal('registerOverlay');
|
openModal('registerOverlay');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('showPasswordReset').addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('resetEmail').value = document.getElementById('loginEmail').value || '';
|
||||||
|
openModal('passwordResetOverlay');
|
||||||
|
});
|
||||||
|
|
||||||
function closeRegister() { closeModal('registerOverlay'); }
|
function closeRegister() { closeModal('registerOverlay'); }
|
||||||
window.closeRegister = closeRegister;
|
window.closeRegister = closeRegister;
|
||||||
|
|
||||||
@@ -404,7 +604,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.getElementById('createAppForm').addEventListener('submit', async e => {
|
document.getElementById('createAppForm').addEventListener('submit', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const name = document.getElementById('appDisplayName').value;
|
const name = document.getElementById('appDisplayName').value;
|
||||||
await createApp(name);
|
const orgId = document.getElementById('appOrganizationId').value;
|
||||||
|
await createApp(name, orgId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create user form
|
// Create user form
|
||||||
@@ -416,6 +617,85 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
await createUser(name, email, password);
|
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
|
// Sidebar navigation
|
||||||
document.querySelectorAll('#sidebarNav a').forEach(link => {
|
document.querySelectorAll('#sidebarNav a').forEach(link => {
|
||||||
link.addEventListener('click', e => {
|
link.addEventListener('click', e => {
|
||||||
@@ -443,17 +723,252 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (state.token) {
|
if (state.token) {
|
||||||
loadCurrentUser().then(() => {
|
loadCurrentUser().then(() => {
|
||||||
showApp();
|
showApp();
|
||||||
const page = window.location.hash.replace('#', '') || 'dashboard';
|
if (state.user.must_change_password) {
|
||||||
navigate(page);
|
navigate('settings');
|
||||||
|
openModal('forcePasswordOverlay');
|
||||||
|
} else {
|
||||||
|
const page = window.location.hash.replace('#', '') || 'dashboard';
|
||||||
|
navigate(page);
|
||||||
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
logout();
|
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
|
// Export functions for inline onclick handlers
|
||||||
window.openCreateAppModal = openCreateAppModal;
|
window.openCreateAppModal = openCreateAppModal;
|
||||||
window.openCreateUserModal = openCreateUserModal;
|
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.confirmDeleteApp = confirmDeleteApp;
|
||||||
window.copyToClipboard = copyToClipboard;
|
window.copyToClipboard = copyToClipboard;
|
||||||
window.closeModal = closeModal;
|
window.closeModal = closeModal;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user