v0.27.0 — user-configurable app subdomains
Users can now customize the subdomain for each app during deployment instead of using a fixed value. The deploy page shows an editable text input with the default pre-filled and the base domain as a suffix. New "subdomain" deploy field type with DNS-safe format validation, reserved name blocklist, and uniqueness check across deployed stacks. Locked after deploy — changing requires Remove + Redeploy. Backward compatible: InjectMissingFields() auto-fills SUBDOMAIN from .felhom.yml defaults for existing deployed apps on next sync/restart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,24 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.27.0 — User-Configurable App Subdomains (2026-02-22)
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **User-configurable subdomains**: Users can now customize the subdomain (e.g., `wiki`, `cloud`, `my-notes`) for each app during deployment, instead of using a fixed value. The deploy page shows an editable text input with the default subdomain pre-filled and the base domain as a suffix (e.g., `[wiki] .demo-felhom.eu`).
|
||||||
|
- **New deploy field type `"subdomain"`** — `internal/stacks/metadata.go`, `deploy.go`: A new field type that is user-editable with a default value, validated, and locked after deployment. Changing the subdomain requires removing the app (clean install) and redeploying.
|
||||||
|
- **Subdomain validation** — `internal/stacks/deploy.go`: Three-layer validation: DNS-safe format (lowercase alphanumeric + hyphens, max 63 chars), reserved name blocklist (`felhom`, `files`, `traefik`, `api`, `www`, `mail`, `admin`, etc.), and uniqueness check across all deployed stacks.
|
||||||
|
- **Backward compatibility** — `internal/stacks/deploy.go`: `InjectMissingFields()` auto-fills `SUBDOMAIN` from the `.felhom.yml` default for existing deployed apps when templates are synced, so no manual intervention is needed.
|
||||||
|
- **`internal/web/handlers.go`** — `stacksHandler()` builds an effective subdomain lookup map (stored env → metadata fallback). `appDetailHandler()` passes `EffectiveSubdomain` to templates.
|
||||||
|
- **`internal/web/templates/deploy.html`** — New `.subdomain-input-group` widget with inline `.domain` suffix. Client-side validation enforces DNS-safe format with real-time lowercasing.
|
||||||
|
- **`internal/web/templates/stacks.html`**, **`app_info.html`** — Subdomain links now read from stored `app.yaml` env (via lookup map) instead of hardcoded metadata, showing the user's actual chosen subdomain.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **`internal/stacks/deploy.go`** — `PreviewDeployValues()` domain case simplified: shows just the base domain now (subdomain is a separate field).
|
||||||
|
- **`internal/web/handlers.go`** — Deploy page domain auto-field no longer prepends `meta.Subdomain + "."`. Passes `DeployedFieldValues` for rendering stored subdomain on settings page.
|
||||||
|
|
||||||
|
#### App Catalog (app-catalog-felhom.eu)
|
||||||
|
- All 51 template `docker-compose.yml` files updated: hardcoded `{subdomain}.${DOMAIN}` replaced with `${SUBDOMAIN}.${DOMAIN}` in Traefik labels, app env vars (APP_URL, trusted domains, webhook URLs, etc.), and comments.
|
||||||
|
- All 51 `.felhom.yml` files updated: added `SUBDOMAIN` deploy field with `type: subdomain` and `default:` matching the existing `subdomain:` metadata value.
|
||||||
|
|
||||||
### v0.26.2 — Show Full App URL on Deploy Page (2026-02-22)
|
### v0.26.2 — Show Full App URL on Deploy Page (2026-02-22)
|
||||||
|
|
||||||
#### Fixed
|
#### Fixed
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ The app catalog lives in a separate Git repository. The controller:
|
|||||||
|
|
||||||
1. Customer sees app card with "Telepites" button
|
1. Customer sees app card with "Telepites" button
|
||||||
2. Deploy page pre-generates and **displays** all auto-values before the user clicks deploy:
|
2. Deploy page pre-generates and **displays** all auto-values before the user clicks deploy:
|
||||||
- `domain` fields: shown as readonly text input with the customer's configured domain
|
- `domain` fields: shown as readonly text input with the customer's configured base domain
|
||||||
|
- `subdomain` fields: editable text input pre-filled with the default from `.felhom.yml`, shown with `.base-domain` suffix. Validated for DNS-safe format, reserved names, and uniqueness across deployed stacks. Locked after deploy — changing requires Remove + Redeploy
|
||||||
- `secret` fields: pre-generated and shown as masked password inputs with a "Megjelenítés" reveal button — user can see/copy all DB passwords and keys before deploying
|
- `secret` fields: pre-generated and shown as masked password inputs with a "Megjelenítés" reveal button — user can see/copy all DB passwords and keys before deploying
|
||||||
- User-configurable inputs (admin password, language, storage path) remain editable
|
- User-configurable inputs (admin password, language, storage path) remain editable
|
||||||
- Section header prompts the user to note down any passwords they need
|
- Section header prompts the user to note down any passwords they need
|
||||||
@@ -180,6 +181,7 @@ When app templates are updated (e.g., a new `APP_KEY` secret is added to `.felho
|
|||||||
- For each deployed stack, compares `.felhom.yml` `deploy_fields` against `app.yaml` env vars
|
- For each deployed stack, compares `.felhom.yml` `deploy_fields` against `app.yaml` env vars
|
||||||
- Missing `secret` fields: auto-generated using the field's generator spec (`password:N`, `hex:N`, `base64key:N`)
|
- Missing `secret` fields: auto-generated using the field's generator spec (`password:N`, `hex:N`, `base64key:N`)
|
||||||
- Missing `domain` fields: filled with the customer's configured domain
|
- Missing `domain` fields: filled with the customer's configured domain
|
||||||
|
- Missing `subdomain` fields: filled with the field's default value or the `.felhom.yml` `subdomain:` metadata
|
||||||
- Other field types (e.g., `text`, `select`): logged as warning for manual configuration
|
- Other field types (e.g., `text`, `select`): logged as warning for manual configuration
|
||||||
- Locked fields are added to the locked list automatically
|
- Locked fields are added to the locked list automatically
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,6 +16,70 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// reservedSubdomains lists subdomains reserved for system use.
|
||||||
|
var reservedSubdomains = map[string]bool{
|
||||||
|
"felhom": true, // controller dashboard
|
||||||
|
"files": true, // filebrowser
|
||||||
|
"traefik": true, // reverse proxy
|
||||||
|
"api": true,
|
||||||
|
"www": true,
|
||||||
|
"mail": true,
|
||||||
|
"smtp": true,
|
||||||
|
"ftp": true,
|
||||||
|
"admin": true,
|
||||||
|
"portal": true,
|
||||||
|
"ssh": true,
|
||||||
|
"ns1": true,
|
||||||
|
"ns2": true,
|
||||||
|
"mx": true,
|
||||||
|
"pop": true,
|
||||||
|
"imap": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var subdomainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
|
||||||
|
|
||||||
|
// validateSubdomain checks that a subdomain is DNS-safe.
|
||||||
|
func validateSubdomain(s string) error {
|
||||||
|
if s == "" {
|
||||||
|
return fmt.Errorf("az aldomain nem lehet üres")
|
||||||
|
}
|
||||||
|
if len(s) > 63 {
|
||||||
|
return fmt.Errorf("az aldomain legfeljebb 63 karakter lehet")
|
||||||
|
}
|
||||||
|
if !subdomainRe.MatchString(s) {
|
||||||
|
return fmt.Errorf("az aldomain csak kisbetűket, számokat és kötőjelet tartalmazhat, és nem kezdődhet/végződhet kötőjellel")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubdomainInUse checks if a subdomain is already used by any deployed stack
|
||||||
|
// other than excludeStack.
|
||||||
|
func (m *Manager) SubdomainInUse(subdomain, excludeStack string) bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
for name, stack := range m.stacks {
|
||||||
|
if name == excludeStack || !stack.Deployed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stackDir := filepath.Dir(stack.ComposePath)
|
||||||
|
appCfg := LoadAppConfig(stackDir)
|
||||||
|
if appCfg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check stored SUBDOMAIN first
|
||||||
|
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd == subdomain {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Backward compat: check metadata subdomain for apps without SUBDOMAIN in env
|
||||||
|
if _, hasSub := appCfg.Env["SUBDOMAIN"]; !hasSub {
|
||||||
|
if stack.Meta.Subdomain == subdomain {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// AppConfig holds the per-app deployment configuration.
|
// AppConfig holds the per-app deployment configuration.
|
||||||
// Saved as app.yaml in each stack directory after first deployment.
|
// Saved as app.yaml in each stack directory after first deployment.
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
@@ -113,6 +178,23 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
|||||||
// Auto-fill from controller config
|
// Auto-fill from controller config
|
||||||
value = m.cfg.Customer.Domain
|
value = m.cfg.Customer.Domain
|
||||||
|
|
||||||
|
case "subdomain":
|
||||||
|
// User-editable with default from metadata
|
||||||
|
if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(userVal))
|
||||||
|
} else if field.Default != "" {
|
||||||
|
value = field.Default
|
||||||
|
}
|
||||||
|
if err := validateSubdomain(value); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if reservedSubdomains[value] {
|
||||||
|
return "", fmt.Errorf("a(z) %q aldomain foglalt rendszer számára", value)
|
||||||
|
}
|
||||||
|
if m.SubdomainInUse(value, req.StackName) {
|
||||||
|
return "", fmt.Errorf("a(z) %q aldomain már használatban van egy másik alkalmazásban", value)
|
||||||
|
}
|
||||||
|
|
||||||
case "secret":
|
case "secret":
|
||||||
// Use pre-generated value if provided by the deploy page (same value the user saw),
|
// Use pre-generated value if provided by the deploy page (same value the user saw),
|
||||||
// otherwise fall back to generating a fresh one.
|
// otherwise fall back to generating a fresh one.
|
||||||
@@ -387,13 +469,8 @@ func (m *Manager) PreviewDeployValues(name string) (map[string]string, error) {
|
|||||||
for _, field := range meta.DeployFields {
|
for _, field := range meta.DeployFields {
|
||||||
switch field.Type {
|
switch field.Type {
|
||||||
case "domain":
|
case "domain":
|
||||||
// Show the full URL the app will be reachable at (subdomain.base_domain).
|
// Show the base domain. The subdomain is now a separate user-editable field.
|
||||||
// This is informational only — DeployStack always stores the base domain.
|
result[field.EnvVar] = m.cfg.Customer.Domain
|
||||||
if meta.Subdomain != "" {
|
|
||||||
result[field.EnvVar] = meta.Subdomain + "." + m.cfg.Customer.Domain
|
|
||||||
} else {
|
|
||||||
result[field.EnvVar] = m.cfg.Customer.Domain
|
|
||||||
}
|
|
||||||
case "secret":
|
case "secret":
|
||||||
if field.Generate == "" {
|
if field.Generate == "" {
|
||||||
continue
|
continue
|
||||||
@@ -531,6 +608,22 @@ func (m *Manager) InjectMissingFields(stackNames []string) {
|
|||||||
}
|
}
|
||||||
injected = append(injected, field.EnvVar)
|
injected = append(injected, field.EnvVar)
|
||||||
|
|
||||||
|
case "subdomain":
|
||||||
|
// Auto-fill from field default or metadata subdomain
|
||||||
|
val := field.Default
|
||||||
|
if val == "" {
|
||||||
|
val = meta.Subdomain
|
||||||
|
}
|
||||||
|
if val == "" {
|
||||||
|
m.logger.Printf("[WARN] Stack %s: new subdomain field %s has no default — skipping", name, field.EnvVar)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appCfg.Env[field.EnvVar] = val
|
||||||
|
if field.LockedAfterDeploy && !containsStr(appCfg.LockedFields, field.EnvVar) {
|
||||||
|
appCfg.LockedFields = append(appCfg.LockedFields, field.EnvVar)
|
||||||
|
}
|
||||||
|
injected = append(injected, field.EnvVar)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
m.logger.Printf("[WARN] Stack %s: new field %s (type=%s) requires manual configuration", name, field.EnvVar, field.Type)
|
m.logger.Printf("[WARN] Stack %s: new field %s (type=%s) requires manual configuration", name, field.EnvVar, field.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ type ResourceHints struct {
|
|||||||
type DeployField struct {
|
type DeployField struct {
|
||||||
EnvVar string `yaml:"env_var" json:"env_var"`
|
EnvVar string `yaml:"env_var" json:"env_var"`
|
||||||
Label string `yaml:"label" json:"label"`
|
Label string `yaml:"label" json:"label"`
|
||||||
Type string `yaml:"type" json:"type"` // domain, secret, password, path, text, select, boolean
|
Type string `yaml:"type" json:"type"` // domain, subdomain, secret, password, path, text, select, boolean
|
||||||
Generate string `yaml:"generate" json:"generate"` // e.g., "password:24", "hex:32", "static:admin"
|
Generate string `yaml:"generate" json:"generate"` // e.g., "password:24", "hex:32", "static:admin"
|
||||||
Default string `yaml:"default" json:"default"`
|
Default string `yaml:"default" json:"default"`
|
||||||
Required bool `yaml:"required" json:"required"`
|
Required bool `yaml:"required" json:"required"`
|
||||||
@@ -112,9 +112,9 @@ func LoadMetadata(stackDir string) Metadata {
|
|||||||
meta.Category = "tools"
|
meta.Category = "tools"
|
||||||
}
|
}
|
||||||
|
|
||||||
// DOMAIN field is always auto-filled — mark it implicitly required
|
// DOMAIN and SUBDOMAIN fields are always auto-filled/required — mark implicitly
|
||||||
for i := range meta.DeployFields {
|
for i := range meta.DeployFields {
|
||||||
if meta.DeployFields[i].Type == "domain" {
|
if meta.DeployFields[i].Type == "domain" || meta.DeployFields[i].Type == "subdomain" {
|
||||||
meta.DeployFields[i].Required = true
|
meta.DeployFields[i].Required = true
|
||||||
meta.DeployFields[i].LockedAfterDeploy = true
|
meta.DeployFields[i].LockedAfterDeploy = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,21 @@ func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
data["StorageLabels"] = storageLabels
|
data["StorageLabels"] = storageLabels
|
||||||
|
|
||||||
|
// Build effective subdomain lookup (stored env > metadata fallback)
|
||||||
|
subdomains := make(map[string]string)
|
||||||
|
for _, stack := range s.stackMgr.GetStacks() {
|
||||||
|
if stack.Deployed {
|
||||||
|
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||||||
|
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
||||||
|
subdomains[stack.Name] = sd
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subdomains[stack.Name] = stack.Meta.Subdomain
|
||||||
|
}
|
||||||
|
data["Subdomains"] = subdomains
|
||||||
|
|
||||||
s.executeTemplate(w, r, "stacks", data)
|
s.executeTemplate(w, r, "stacks", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,12 +274,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
|||||||
if alreadyDeployed && appCfg != nil {
|
if alreadyDeployed && appCfg != nil {
|
||||||
for _, f := range meta.AutoGeneratedFields() {
|
for _, f := range meta.AutoGeneratedFields() {
|
||||||
if val, ok := appCfg.Env[f.EnvVar]; ok {
|
if val, ok := appCfg.Env[f.EnvVar]; ok {
|
||||||
// For domain fields show the full URL (subdomain.base_domain) as displayed on app cards.
|
autoFieldValues[f.EnvVar] = val
|
||||||
if f.Type == "domain" && meta.Subdomain != "" {
|
|
||||||
autoFieldValues[f.EnvVar] = meta.Subdomain + "." + val
|
|
||||||
} else {
|
|
||||||
autoFieldValues[f.EnvVar] = val
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if !alreadyDeployed {
|
} else if !alreadyDeployed {
|
||||||
@@ -275,6 +285,10 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
data["AutoFieldValues"] = autoFieldValues
|
data["AutoFieldValues"] = autoFieldValues
|
||||||
|
// For deployed apps, pass stored field values so subdomain and other user fields show current values
|
||||||
|
if alreadyDeployed && appCfg != nil {
|
||||||
|
data["DeployedFieldValues"] = appCfg.Env
|
||||||
|
}
|
||||||
// Storage paths with free space info for deploy dropdown
|
// Storage paths with free space info for deploy dropdown
|
||||||
var deployPaths []DeployStoragePath
|
var deployPaths []DeployStoragePath
|
||||||
for _, sp := range s.settings.GetSchedulableStoragePaths() {
|
for _, sp := range s.settings.GetSchedulableStoragePaths() {
|
||||||
@@ -406,6 +420,12 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine effective subdomain (stored env > metadata fallback)
|
||||||
|
effectiveSubdomain := found.Meta.Subdomain
|
||||||
|
if sd, ok := currentValues["SUBDOMAIN"]; ok && sd != "" {
|
||||||
|
effectiveSubdomain = sd
|
||||||
|
}
|
||||||
|
|
||||||
data := s.baseData("stacks", found.Meta.DisplayName)
|
data := s.baseData("stacks", found.Meta.DisplayName)
|
||||||
data["Stack"] = found
|
data["Stack"] = found
|
||||||
data["Meta"] = found.Meta
|
data["Meta"] = found.Meta
|
||||||
@@ -414,6 +434,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
|||||||
data["CurrentValues"] = currentValues
|
data["CurrentValues"] = currentValues
|
||||||
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
||||||
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
|
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
|
||||||
|
data["EffectiveSubdomain"] = effectiveSubdomain
|
||||||
|
|
||||||
s.executeTemplate(w, r, "app_info", data)
|
s.executeTemplate(w, r, "app_info", data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
{{if .Stack.Deployed}}
|
{{if .Stack.Deployed}}
|
||||||
<span class="stack-state-badge state-{{stateColor .Stack.State}}">{{stateLabel .Stack.State}}</span>
|
<span class="stack-state-badge state-{{stateColor .Stack.State}}">{{stateLabel .Stack.State}}</span>
|
||||||
{{if .Stack.Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
{{if .Stack.Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||||
<a href="https://{{.Meta.Subdomain}}.{{.Domain}}" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>
|
{{if .EffectiveSubdomain}}<a href="https://{{.EffectiveSubdomain}}.{{.Domain}}" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>{{end}}
|
||||||
<a href="/stacks/{{.Stack.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
<a href="/stacks/{{.Stack.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||||
{{if .Stack.Orphaned}}
|
{{if .Stack.Orphaned}}
|
||||||
<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Stack.Name}}')">Törlés</button>
|
<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Stack.Name}}')">Törlés</button>
|
||||||
|
|||||||
@@ -270,7 +270,22 @@
|
|||||||
{{if .LockedAfterDeploy}}<span class="locked-hint">telepítés után nem módosítható</span>{{end}}
|
{{if .LockedAfterDeploy}}<span class="locked-hint">telepítés után nem módosítható</span>{{end}}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{{if eq .Type "select"}}
|
{{if eq .Type "subdomain"}}
|
||||||
|
<div class="subdomain-input-group">
|
||||||
|
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
|
||||||
|
class="form-control subdomain-input"
|
||||||
|
value="{{if and $.AlreadyDeployed $.DeployedFieldValues}}{{index $.DeployedFieldValues .EnvVar}}{{else}}{{.Default}}{{end}}"
|
||||||
|
placeholder="aldomain"
|
||||||
|
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
|
||||||
|
required
|
||||||
|
{{if $.AlreadyDeployed}}disabled{{end}}
|
||||||
|
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')">
|
||||||
|
<span class="subdomain-suffix">.{{$.Domain}}</span>
|
||||||
|
</div>
|
||||||
|
{{if not $.AlreadyDeployed}}
|
||||||
|
<span class="form-hint">Az aldomain telepítés után nem módosítható. Megváltoztatáshoz az alkalmazás eltávolítása és újratelepítése szükséges (minden adat törlődik).</span>
|
||||||
|
{{end}}
|
||||||
|
{{else if eq .Type "select"}}
|
||||||
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
|
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
|
||||||
{{if $.AlreadyDeployed}}disabled{{end}}>
|
{{if $.AlreadyDeployed}}disabled{{end}}>
|
||||||
{{range .Options}}
|
{{range .Options}}
|
||||||
@@ -510,6 +525,17 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client-side validation: check subdomain format
|
||||||
|
const subdomainField = e.target.querySelector('.subdomain-input');
|
||||||
|
if (subdomainField && !subdomainField.disabled) {
|
||||||
|
const sd = subdomainField.value.trim().toLowerCase();
|
||||||
|
if (!sd || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sd)) {
|
||||||
|
alert('Az aldomain csak kisbetűket, számokat és kötőjelet tartalmazhat, és nem kezdődhet/végződhet kötőjellel.');
|
||||||
|
subdomainField.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Client-side validation: check all required fields
|
// Client-side validation: check all required fields
|
||||||
const requiredFields = e.target.querySelectorAll('input[required], select[required]');
|
const requiredFields = e.target.querySelectorAll('input[required], select[required]');
|
||||||
for (const rf of requiredFields) {
|
for (const rf of requiredFields) {
|
||||||
|
|||||||
@@ -24,9 +24,10 @@
|
|||||||
alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
||||||
<div>
|
<div>
|
||||||
<h3>{{.Meta.DisplayName}}</h3>
|
<h3>{{.Meta.DisplayName}}</h3>
|
||||||
{{if .Meta.Subdomain}}
|
{{$subdomain := index $.Subdomains .Name}}
|
||||||
<a class="subdomain-link" href="https://{{.Meta.Subdomain}}.{{$.Domain}}" target="_blank">
|
{{if $subdomain}}
|
||||||
{{.Meta.Subdomain}}.{{$.Domain}} ↗
|
<a class="subdomain-link" href="https://{{$subdomain}}.{{$.Domain}}" target="_blank">
|
||||||
|
{{$subdomain}}.{{$.Domain}} ↗
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -574,6 +574,13 @@ select.form-control { appearance: auto; }
|
|||||||
select.form-control option { background: var(--bg-secondary); color: var(--text-primary); }
|
select.form-control option { background: var(--bg-secondary); color: var(--text-primary); }
|
||||||
.input-with-button { display: flex; gap: .5rem; }
|
.input-with-button { display: flex; gap: .5rem; }
|
||||||
.input-with-button .form-control { flex: 1; }
|
.input-with-button .form-control { flex: 1; }
|
||||||
|
.subdomain-input-group { display: flex; align-items: center; }
|
||||||
|
.subdomain-input-group .subdomain-input { max-width: 14rem; border-top-right-radius: 0; border-bottom-right-radius: 0; border-right: none; }
|
||||||
|
.subdomain-input-group .subdomain-suffix {
|
||||||
|
padding: .55rem .75rem; background: var(--bg-primary); border: 1px solid var(--border-color);
|
||||||
|
border-left: none; border-radius: 0 8px 8px 0; color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono, monospace); font-size: .9rem; white-space: nowrap;
|
||||||
|
}
|
||||||
.form-hint { display: block; font-size: .8rem; color: var(--text-muted); margin-top: .25rem; }
|
.form-hint { display: block; font-size: .8rem; color: var(--text-muted); margin-top: .25rem; }
|
||||||
.required { color: var(--red); }
|
.required { color: var(--red); }
|
||||||
.locked-hint { font-size: .75rem; color: var(--text-muted); font-weight: 400; margin-left: .5rem; }
|
.locked-hint { font-size: .75rem; color: var(--text-muted); font-weight: 400; margin-left: .5rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user