feat(admin): add template repository management
CI / validate (pull_request) Failing after 8m0s

This commit is contained in:
2026-08-14 15:29:01 +02:00
parent 83fb183e1d
commit f235d8f79b
13 changed files with 356 additions and 1 deletions
+2
View File
@@ -68,6 +68,8 @@ Templates are stored under `${DOGAMA_DATA_PATH:-./data}/templates` on the Docker
Create one directory per game and place its `template.yaml` (plus any local assets it references) in that directory. Open **Catalog** as an administrator and select **Scan** to apply additions, edits and removals without restarting the application. Invalid templates are ignored while valid ones remain available; the Scan result identifies each invalid directory without exposing host paths or secrets. See [the template guide](docs/operations/local-templates.md) for the complete format.
Administrators can also record future sources in **Administration → Template repositories**. These name/HTTP(S)-URL definitions are stored locally only. They do not download, clone, fetch, authenticate to, or synchronize templates; **Catalog → Scan** continues to read only `/var/lib/dogama/templates`.
## Ports
| Port | Exposure | Purpose |
+1
View File
@@ -72,6 +72,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Audit retention defaults to 30 days and 10,000 entries; zero explicitly selects unlimited retention/count within documented bounds.
- At least one active global administrator is always retained; deactivation revokes that user's sessions atomically.
- The local template directory (`/var/lib/dogama/templates`, under the application data bind mount) is the catalog source of truth. Bundled templates are copied only when their destination files are absent; an administrator Scan validates each directory independently and refreshes the available SQLite index without network fetches.
- Administrators can persist bounded HTTP(S) template-repository definitions for future use. They are configuration only: remote retrieval, authentication, synchronization and automatic updates are deliberately unavailable, and Catalog Scan remains local-only.
## Known limitations and debt
+6
View File
@@ -65,3 +65,9 @@ Absolute paths, traversal and symlinked parent directories are refused. INI chan
Administrators deploy from **Catalog → game → Deploy**. The visible instance name is converted deterministically to the safe technical slug used for mount paths (for example `Été / Serveur #2` becomes `ete-serveur-2`). DoGaMa derives every mount below its configured server root, validates a canonical preview and asks only the restricted agent to create and start the container.
When the template supports imports, the form accepts ZIP, TAR, TAR.GZ and TAR.ZST save archives. Uploads are size-bounded, staged under the configured import root with generated names, checked for traversal and expected save layout. Deployment order is **install (stopped) → import → apply resolved configuration → start**, so DoGaMa form values intentionally take priority over configuration files present in an imported save. Invalid staging data is removed and a configuration failure prevents startup.
## Configured template repositories
**Administration → Template repositories** lets administrators store a name and an HTTP(S) URL for a future template source. The URL is validated and persisted in SQLite, but it is never requested by the browser or application. Remote synchronization, Git clone/fetch, HTTP downloads, provider APIs, authentication and scheduled updates are not implemented.
Configured repositories therefore do not affect **Catalog → Scan**. Scan remains strictly local and reads only `/var/lib/dogama/templates`.
+7
View File
@@ -36,6 +36,13 @@ CREATE TABLE templates (
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE template_repositories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 120),
url TEXT NOT NULL UNIQUE COLLATE NOCASE CHECK (length(url) BETWEEN 1 AND 2048),
created_at TEXT NOT NULL
);
CREATE INDEX template_repositories_name_idx ON template_repositories(name COLLATE NOCASE, id);
CREATE TABLE template_versions (
template_id TEXT NOT NULL REFERENCES templates(id) ON DELETE RESTRICT,
version TEXT NOT NULL,
@@ -0,0 +1,68 @@
package sqlite
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/templaterepo"
)
func (r *Repository) CreateTemplateRepository(ctx context.Context, in templaterepo.Input) (templaterepo.Entry, error) {
in, err := templaterepo.Validate(in)
if err != nil {
return templaterepo.Entry{}, err
}
id, err := templateRepositoryID()
if err != nil {
return templaterepo.Entry{}, err
}
entry := templaterepo.Entry{ID: id, Name: in.Name, URL: in.URL, CreatedAt: r.now().UTC().Format(time.RFC3339Nano)}
if _, err := r.db.ExecContext(ctx, `INSERT INTO template_repositories(id,name,url,created_at) VALUES(?,?,?,?)`, entry.ID, entry.Name, entry.URL, entry.CreatedAt); err != nil {
return templaterepo.Entry{}, fmt.Errorf("create template repository: %w", err)
}
return entry, nil
}
func (r *Repository) ListTemplateRepositories(ctx context.Context) ([]templaterepo.Entry, error) {
rows, err := r.db.QueryContext(ctx, `SELECT id,name,url,created_at FROM template_repositories ORDER BY name COLLATE NOCASE, id`)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []templaterepo.Entry
for rows.Next() {
var entry templaterepo.Entry
if err := rows.Scan(&entry.ID, &entry.Name, &entry.URL, &entry.CreatedAt); err != nil {
return nil, err
}
entries = append(entries, entry)
}
return entries, rows.Err()
}
func (r *Repository) DeleteTemplateRepository(ctx context.Context, id string) error {
if id == "" {
return templaterepo.ErrInvalid
}
result, err := r.db.ExecContext(ctx, `DELETE FROM template_repositories WHERE id=?`, id)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected == 0 {
return sql.ErrNoRows
}
return nil
}
func templateRepositoryID() (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", errors.New("generate template repository identifier")
}
return hex.EncodeToString(bytes), nil
}
@@ -0,0 +1,53 @@
package sqlite_test
import (
"context"
"errors"
"path/filepath"
"testing"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/templaterepo"
)
func TestTemplateRepositoriesPersistValidateAndDelete(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "dogama.db")
db, err := sqlite.Open(ctx, path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository := sqlite.NewRepository(db)
if _, err := repository.CreateTemplateRepository(ctx, templaterepo.Input{Name: "", URL: "https://templates.example.test"}); !errors.Is(err, templaterepo.ErrInvalid) {
t.Fatalf("empty name error = %v", err)
}
for _, invalid := range []string{"", "javascript:alert(1)", "data:text/plain,no", "file:///tmp/templates", "ftp://templates.example.test", "https:///missing-host"} {
if _, err := repository.CreateTemplateRepository(ctx, templaterepo.Input{Name: "Invalid", URL: invalid}); !errors.Is(err, templaterepo.ErrInvalid) {
t.Fatalf("invalid URL %q error = %v", invalid, err)
}
}
created, err := repository.CreateTemplateRepository(ctx, templaterepo.Input{Name: " Official ", URL: " https://templates.example.test/index.json "})
if err != nil {
t.Fatal(err)
}
if created.Name != "Official" || created.URL != "https://templates.example.test/index.json" || created.ID == "" {
t.Fatalf("created = %#v", created)
}
if _, err := repository.CreateTemplateRepository(ctx, templaterepo.Input{Name: "Duplicate", URL: created.URL}); err == nil {
t.Fatal("duplicate URL was accepted")
}
// A fresh repository wrapper observes the persisted SQLite row.
repository = sqlite.NewRepository(db)
entries, err := repository.ListTemplateRepositories(ctx)
if err != nil || len(entries) != 1 || entries[0] != created {
t.Fatalf("persisted entries = %#v, error = %v", entries, err)
}
if err := repository.DeleteTemplateRepository(ctx, created.ID); err != nil {
t.Fatal(err)
}
entries, err = repository.ListTemplateRepositories(ctx)
if err != nil || len(entries) != 0 {
t.Fatalf("entries after deletion = %#v, error = %v", entries, err)
}
}
+48
View File
@@ -0,0 +1,48 @@
// Package templaterepo defines locally configured future template sources.
// It deliberately does not retrieve, synchronize, or parse remote content.
package templaterepo
import (
"context"
"errors"
"net/url"
"strings"
)
const (
maxNameLength = 120
maxURLLength = 2048
)
var ErrInvalid = errors.New("invalid template repository")
type Entry struct {
ID, Name, URL string
CreatedAt string
}
type Input struct {
Name string
URL string
}
type Repository interface {
CreateTemplateRepository(context.Context, Input) (Entry, error)
ListTemplateRepositories(context.Context) ([]Entry, error)
DeleteTemplateRepository(context.Context, string) error
}
// Validate accepts only bounded absolute HTTP(S) URLs. It performs no network
// operation: configured sources are data for a future milestone only.
func Validate(in Input) (Input, error) {
in.Name = strings.TrimSpace(in.Name)
in.URL = strings.TrimSpace(in.URL)
if in.Name == "" || len(in.Name) > maxNameLength || in.URL == "" || len(in.URL) > maxURLLength {
return Input{}, ErrInvalid
}
parsed, err := url.ParseRequestURI(in.URL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return Input{}, ErrInvalid
}
return in, nil
}
+8
View File
@@ -56,6 +56,14 @@ func init() {
messages[language][key] = value
}
}
for language, values := range map[string]map[string]string{
"en": {"template_repositories.title": "Template repositories", "template_repositories.heading": "Template repositories", "template_repositories.introduction": "Configure future template sources. Remote synchronization is not implemented.", "template_repositories.users": "Users", "template_repositories.new": "New repository", "template_repositories.name": "Name", "template_repositories.url": "URL", "template_repositories.add": "Add", "template_repositories.delete": "Delete", "template_repositories.delete_confirm": "Delete this template repository?", "template_repositories.not_implemented": "Feature not implemented", "template_repositories.empty": "No template repositories configured.", "template_repositories.validation_error": "Enter a name and a valid HTTP or HTTPS URL.", "template_repositories.duplicate_error": "This template repository URL is already configured.", "template_repositories.not_found": "Template repository not found."},
"fr": {"template_repositories.title": "Dépôts de templates", "template_repositories.heading": "Dépôts de templates", "template_repositories.introduction": "Configurez de futures sources de templates. La synchronisation distante n’est pas implémentée.", "template_repositories.users": "Utilisateurs", "template_repositories.new": "Nouveau dépôt", "template_repositories.name": "Nom", "template_repositories.url": "URL", "template_repositories.add": "Ajouter", "template_repositories.delete": "Supprimer", "template_repositories.delete_confirm": "Supprimer ce dépôt de templates ?", "template_repositories.not_implemented": "Fonctionnalité non implémentée", "template_repositories.empty": "Aucun dépôt de templates configuré.", "template_repositories.validation_error": "Saisissez un nom et une URL HTTP ou HTTPS valide.", "template_repositories.duplicate_error": "Cette URL de dépôt de templates est déjà configurée.", "template_repositories.not_found": "Dépôt de templates introuvable."},
} {
for key, value := range values {
messages[language][key] = value
}
}
for language, values := range map[string]map[string]string{
"en": {"settings.notification_general": "General", "settings.notification_language_help": "Choose the language used in delivered notifications.", "settings.notification_email": "Email / SMTP", "settings.notification_discord": "Discord", "settings.notification_gotify": "Gotify", "settings.sender_name": "Sender name", "settings.sender_email": "Sender email", "settings.smtp_server": "SMTP server", "settings.port": "Port", "settings.username": "Username", "settings.password": "Password", "settings.password_retain": "leave empty to retain on edit", "settings.tls_mode": "TLS mode", "settings.test_recipient": "Test recipient (optional)", "settings.save_smtp": "Save SMTP", "settings.webhook_url": "Webhook URL", "settings.save_discord": "Save Discord", "settings.gotify_help": "Create an application in Gotify, then enter its server URL and application token here.", "settings.server_url": "Server URL", "settings.application_token": "Application token", "settings.save_gotify": "Save Gotify", "settings.configured": "configured", "settings.not_configured": "not configured"},
"fr": {"settings.notification_general": "Général", "settings.notification_language_help": "Choisissez la langue utilisée pour les notifications envoyées.", "settings.notification_email": "E-mail / SMTP", "settings.notification_discord": "Discord", "settings.notification_gotify": "Gotify", "settings.sender_name": "Nom de l'expéditeur", "settings.sender_email": "E-mail de l'expéditeur", "settings.smtp_server": "Serveur SMTP", "settings.port": "Port", "settings.username": "Nom d'utilisateur", "settings.password": "Mot de passe", "settings.password_retain": "laissez vide pour le conserver lors d'une modification", "settings.tls_mode": "Mode TLS", "settings.test_recipient": "Destinataire de test (facultatif)", "settings.save_smtp": "Enregistrer SMTP", "settings.webhook_url": "URL du webhook", "settings.save_discord": "Enregistrer Discord", "settings.gotify_help": "Créez une application dans Gotify, puis saisissez ici l'URL du serveur et le jeton de l'application.", "settings.server_url": "URL du serveur", "settings.application_token": "Jeton d'application", "settings.save_gotify": "Enregistrer Gotify", "settings.configured": "configuré", "settings.not_configured": "non configuré"},
+11
View File
@@ -31,6 +31,7 @@ import (
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/templaterepo"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/webaccess"
)
@@ -79,6 +80,7 @@ type repository interface {
instance.Repository
instance.LifecycleRepository
authorization.Repository
templaterepo.Repository
}
type pageData struct {
@@ -115,6 +117,8 @@ type pageData struct {
CatalogDetail *catalog.Template
CatalogScan *catalog.ScanResult
CatalogScanner bool
TemplateRepositories []templaterepo.Entry
TemplateRepositoryInput templaterepo.Input
Deployment *deploymentPage
InstanceDetail *instanceDetailPage
}
@@ -347,6 +351,8 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
mux.HandleFunc("POST /admin/notification-channels/{id}/delete", s.notificationDeleteForm)
mux.HandleFunc("POST /admin/audit-policy", s.auditPolicyForm)
mux.HandleFunc("POST /admin/audit-purge", s.auditPurgeForm)
mux.HandleFunc("POST /administration/template-repositories", s.templateRepositoryCreateForm)
mux.HandleFunc("POST /administration/template-repositories/{id}/delete", s.templateRepositoryDeleteForm)
mux.HandleFunc("GET /audit", s.auditPage)
mux.HandleFunc("GET /catalog", s.catalogPage)
mux.HandleFunc("POST /catalog/scan", s.catalogScanForm)
@@ -360,6 +366,7 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
mux.HandleFunc("POST /account/language", s.accountLanguageForm)
mux.HandleFunc("GET /administration", s.settingsPage)
mux.HandleFunc("GET /administration/users", s.usersPage)
mux.HandleFunc("GET /administration/template-repositories", s.templateRepositoriesPage)
mux.HandleFunc("POST /administration/users", s.userCreateForm)
mux.HandleFunc("POST /administration/users/{id}", s.userUpdateForm)
mux.HandleFunc("POST /administration/users/{id}/memberships/{instanceID}", s.userMembershipForm)
@@ -439,6 +446,10 @@ func auditAction(method, path string) string {
switch {
case method == http.MethodPost && (path == "/api/v1/admin/users" || path == "/administration/users"):
return "user.create"
case method == http.MethodPost && path == "/administration/template-repositories":
return "template_repository.create"
case method == http.MethodPost && strings.HasPrefix(path, "/administration/template-repositories/"):
return "template_repository.delete"
case (method == http.MethodPut && strings.HasPrefix(path, "/api/v1/admin/users/")) || (method == http.MethodPost && strings.HasPrefix(path, "/administration/users/") && !strings.Contains(path, "/memberships/")):
return "user.change"
case strings.Contains(path, "/memberships/") && strings.Contains(path, "/permissions/"):
+60
View File
@@ -0,0 +1,60 @@
package web
import (
"database/sql"
"errors"
"net/http"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/templaterepo"
)
func (s *server) templateRepositoriesPage(w http.ResponseWriter, r *http.Request) {
s.templateRepositoriesRender(w, r, http.StatusOK, templaterepo.Input{}, "")
}
func (s *server) templateRepositoryCreateForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
in := templaterepo.Input{Name: r.FormValue("name"), URL: r.FormValue("url")}
if _, err := s.repository.CreateTemplateRepository(r.Context(), in); err != nil {
if errors.Is(err, templaterepo.ErrInvalid) {
s.templateRepositoriesRender(w, r, http.StatusUnprocessableEntity, in, localized(s.language(r, actor.Language), "template_repositories.validation_error"))
return
}
s.templateRepositoriesRender(w, r, http.StatusUnprocessableEntity, in, localized(s.language(r, actor.Language), "template_repositories.duplicate_error"))
return
}
http.Redirect(w, r, "/administration/template-repositories", http.StatusSeeOther)
}
func (s *server) templateRepositoryDeleteForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
if !ok {
return
}
if err := s.repository.DeleteTemplateRepository(r.Context(), r.PathValue("id")); err != nil {
if errors.Is(err, templaterepo.ErrInvalid) || errors.Is(err, sql.ErrNoRows) {
s.problem(w, http.StatusNotFound, localized(s.language(r, actor.Language), "template_repositories.not_found"))
return
}
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
http.Redirect(w, r, "/administration/template-repositories", http.StatusSeeOther)
}
func (s *server) templateRepositoriesRender(w http.ResponseWriter, r *http.Request, status int, in templaterepo.Input, formError string) {
data, ok := s.adminPageData(w, r, "Template repositories", "administration")
if !ok {
return
}
entries, err := s.repository.ListTemplateRepositories(r.Context())
if err != nil {
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
data.TemplateRepositories, data.TemplateRepositoryInput, data.Error = entries, in, formError
s.render(w, status, "template-repositories.html", data)
}
@@ -0,0 +1,82 @@
package web
import (
"context"
"io"
"log/slog"
"net/http"
"net/url"
"path/filepath"
"strings"
"testing"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestTemplateRepositoriesAdministration(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository := sqlite.NewRepository(db)
authService := auth.New(db)
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
manager, err := authService.CreateUser(ctx, "manager", "correct horse battery staple", "user")
if err != nil {
t.Fatal(err)
}
viewer, err := authService.CreateUser(ctx, "viewer", "correct horse battery staple", "user")
if err != nil {
t.Fatal(err)
}
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
managerSession, err := authService.Login(ctx, manager.Username, "correct horse battery staple", "192.0.2.2:1234")
if err != nil {
t.Fatal(err)
}
viewerSession, err := authService.Login(ctx, viewer.Username, "correct horse battery staple", "192.0.2.3:1234")
if err != nil {
t.Fatal(err)
}
handler, err := NewHandlerWithRepository(authService, repository, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
adminCookies := []*http.Cookie{{Name: sessionCookie, Value: adminSession.Token}, {Name: csrfCookie, Value: adminSession.CSRFToken}}
page := request(t, handler, http.MethodGet, "/administration/template-repositories", adminCookies)
assertStatus(t, page, http.StatusOK)
for _, expected := range []string{"Template repositories", "New repository", "No template repositories configured.", "Feature not implemented"} {
if !strings.Contains(page.Body.String(), expected) {
t.Fatalf("page missing %q: %s", expected, page.Body.String())
}
}
assertStatus(t, request(t, handler, http.MethodGet, "/administration/template-repositories", []*http.Cookie{{Name: sessionCookie, Value: managerSession.Token}, {Name: csrfCookie, Value: managerSession.CSRFToken}}), http.StatusForbidden)
assertStatus(t, request(t, handler, http.MethodGet, "/administration/template-repositories", []*http.Cookie{{Name: sessionCookie, Value: viewerSession.Token}, {Name: csrfCookie, Value: viewerSession.CSRFToken}}), http.StatusForbidden)
denied := formRequest(t, handler, "/administration/template-repositories", url.Values{"name": {"Denied"}, "url": {"https://denied.example.test"}}, adminCookies[0])
assertStatus(t, denied, http.StatusForbidden)
created := formRequest(t, handler, "/administration/template-repositories", url.Values{"csrf_token": {adminSession.CSRFToken}, "name": {"Community"}, "url": {"https://community.example.test/templates"}}, adminCookies...)
assertStatus(t, created, http.StatusSeeOther)
entries, err := repository.ListTemplateRepositories(ctx)
if err != nil || len(entries) != 1 || entries[0].Name != "Community" {
t.Fatalf("created entries = %#v, error = %v", entries, err)
}
invalid := formRequest(t, handler, "/administration/template-repositories", url.Values{"csrf_token": {adminSession.CSRFToken}, "name": {"Unsafe"}, "url": {"javascript:alert(1)"}}, adminCookies...)
assertStatus(t, invalid, http.StatusUnprocessableEntity)
if !strings.Contains(invalid.Body.String(), "Enter a name and a valid HTTP or HTTPS URL.") {
t.Fatalf("validation feedback missing: %s", invalid.Body.String())
}
deleteResponse := formRequest(t, handler, "/administration/template-repositories/"+entries[0].ID+"/delete", url.Values{"csrf_token": {adminSession.CSRFToken}}, adminCookies...)
assertStatus(t, deleteResponse, http.StatusSeeOther)
entries, err = repository.ListTemplateRepositories(ctx)
if err != nil || len(entries) != 0 {
t.Fatalf("entries after deletion = %#v, error = %v", entries, err)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
<body class="app-body">{{template "sidebar" .}}
<main class="app-main">
<div class="page-heading"><div><p class="eyebrow">{{.Msg "settings.eyebrow"}}</p><h1>Administration</h1><p>{{.Msg "settings.introduction"}}</p></div></div>
<nav class="tabs" aria-label="{{.Msg "settings.tabs"}}"><a href="/administration/users">Users</a><a href="#web-access">{{.Msg "settings.web_access"}}</a><a href="#notifications">{{.Msg "settings.notifications"}}</a><a href="#audit">{{.Msg "settings.audit"}}</a><a href="#containers">{{.Msg "settings.containers"}}</a></nav>
<nav class="tabs" aria-label="{{.Msg "settings.tabs"}}"><a href="/administration/users">{{.Msg "template_repositories.users"}}</a><a href="#web-access">{{.Msg "settings.web_access"}}</a><a href="#notifications">{{.Msg "settings.notifications"}}</a><a href="/administration/template-repositories">{{.Msg "template_repositories.heading"}}</a><a href="#audit">{{.Msg "settings.audit"}}</a><a href="#containers">{{.Msg "settings.containers"}}</a></nav>
<section class="panel settings-section" id="web-access"><h2>{{.Msg "settings.web_access"}}</h2><p>{{.Msg "settings.web_help"}}</p><form method="post" action="/admin/web-access"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label class="check"><input type="checkbox" name="require_https"{{if .RequireHTTPS}} checked{{end}}> {{.Msg "settings.require_https"}}</label><p class="help">{{.Msg "settings.https_help"}}</p><label>{{.Msg "settings.canonical_url"}}<input name="canonical_url" type="url" value="{{.CanonicalURL}}" placeholder="https://dogama.example.com"></label><p class="help">{{.Msg "settings.canonical_help"}}</p><button type="submit">{{.Msg "settings.save_web"}}</button></form></section>
<section class="panel settings-section" id="notifications"><h2>{{.Msg "settings.channels"}}</h2><p>{{.Msg "settings.channels_help"}}</p><div class="tabs"><a href="#notification-general">{{.Msg "settings.notification_general"}}</a><a href="#email">{{.Msg "settings.notification_email"}}</a><a href="#discord">{{.Msg "settings.notification_discord"}}</a><a href="#gotify">{{.Msg "settings.notification_gotify"}}</a></div>
<section id="notification-general"><h3>{{.Msg "settings.notification_general"}}</h3><p>{{.Msg "settings.notification_language_help"}}</p><form method="post" action="/admin/notification-language"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{.Msg "language"}}<select name="language">{{range .Languages}}<option value="{{.Code}}"{{if eq $.NotificationLanguage .Code}} selected{{end}}>{{.Display}}</option>{{end}}</select></label><button type="submit">{{.Msg "language.save"}}</button></form></section>
@@ -0,0 +1,9 @@
{{define "template-repositories.html"}}
<!doctype html><html lang="{{.Language}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head>
<body class="app-body">{{template "sidebar" .}}<main class="app-main">
<div class="page-heading"><div><p class="eyebrow">Administration</p><h1>{{.Msg "template_repositories.heading"}}</h1><p>{{.Msg "template_repositories.introduction"}}</p></div><a class="button-secondary link-button" href="/administration">Administration</a></div>
<nav class="tabs" aria-label="{{.Msg "settings.tabs"}}"><a href="/administration/users">{{.Msg "template_repositories.users"}}</a><a href="/administration#notifications">{{.Msg "settings.notifications"}}</a><a href="/administration/template-repositories">{{.Msg "template_repositories.heading"}}</a><a href="/administration#audit">{{.Msg "settings.audit"}}</a></nav>
<section class="panel settings-section"><details{{if .Error}} open{{end}}><summary class="button-secondary link-button">{{.Msg "template_repositories.new"}}</summary>{{if .Error}}<p role="alert">{{.Error}}</p>{{end}}<form method="post" action="/administration/template-repositories"><div class="form-grid"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>{{.Msg "template_repositories.name"}}<input required maxlength="120" name="name" value="{{.TemplateRepositoryInput.Name}}"></label><label>{{.Msg "template_repositories.url"}}<input required maxlength="2048" type="url" name="url" value="{{.TemplateRepositoryInput.URL}}" placeholder="https://templates.example.com"></label></div><button type="submit">{{.Msg "template_repositories.add"}}</button></form></details></section>
<section class="panel settings-section"><h2>{{.Msg "template_repositories.heading"}}</h2><p>{{.Msg "template_repositories.not_implemented"}}</p>{{if .TemplateRepositories}}<ul class="channel-list">{{range .TemplateRepositories}}<li><strong>{{.Name}}</strong><small>{{.URL}} · {{$.Msg "template_repositories.not_implemented"}}</small><form method="post" action="/administration/template-repositories/{{.ID}}/delete" onsubmit="return confirm('{{$.Msg "template_repositories.delete_confirm"}}');"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><button type="submit" aria-label="{{$.Msg "template_repositories.delete"}} {{.Name}}">🗑 {{$.Msg "template_repositories.delete"}}</button></form></li>{{end}}</ul>{{else}}<p>{{.Msg "template_repositories.empty"}}</p>{{end}}</section>
</main></body></html>
{{end}}