cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go
index f5ad201..e586a03 100644
--- a/cmd/gitfed-web/handlers_repo.go
+++ b/cmd/gitfed-web/handlers_repo.go
@@ -507,7 +507,25 @@ func (s *server) handleCollabGrant(w http.ResponseWriter, r *http.Request) {
redirectWithMsg(w, r, back, err.Error(), true)
return
}
- redirectWithMsg(w, r, back, i18n.T(lang, "repo.msg_granted", principal, roleLabel(lang, string(role))), false)
+
+ grantedMsg := i18n.T(lang, "repo.msg_granted", principal, roleLabel(lang, string(role)))
+ if _, domain, ok := strings.Cut(principal, "@"); ok && domain != s.domain {
+ // A federated grant only becomes usable once this domain's CA is
+ // trusted — GrantCollaborator queues that automatically, but
+ // (with the default whitelist policy) it needs an admin to
+ // actually approve it, a separate step nothing else prompts for.
+ // Missing it looks like a working grant followed by a confusing
+ // SSH "Permission denied (publickey)" for the collaborator.
+ if trusted, err := s.ops.ListTrustedCAs(); err == nil {
+ for _, t := range trusted {
+ if t.Domain == domain && t.Status == store.TrustPending {
+ redirectWithWarnMsg(w, r, back, i18n.T(lang, "repo.msg_granted_pending_trust", principal, domain))
+ return
+ }
+ }
+ }
+ }
+ redirectWithMsg(w, r, back, grantedMsg, false)
}
func (s *server) handleCollabRevoke(w http.ResponseWriter, r *http.Request) {
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go
index 5bd923c..07a2d8f 100644
--- a/cmd/gitfed-web/render.go
+++ b/cmd/gitfed-web/render.go
@@ -593,6 +593,7 @@ const shellHeadSrc = `<!doctype html>
.msg { padding: 0.6rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
.msg.ok { background: var(--ok-bg); color: var(--ok-fg); }
.msg.err { background: var(--danger-bg); color: var(--danger-fg); }
+ .msg.warn { background: var(--pending-bg); color: var(--pending-fg); }
.badge { display: inline-block; padding: 0.12rem 0.55rem; border-radius: 999px; font-size: 0.72rem; font-family: var(--mono); }
.badge.pending { background: var(--pending-bg); color: var(--pending-fg); }
.badge.trusted { background: var(--ok-bg); color: var(--ok-fg); }
@@ -667,6 +668,11 @@ const shellHeadSrc = `<!doctype html>
<a href="/lang/fr?next={{.NextPath}}"{{if eq .Lang "fr"}} class="active"{{end}}>FR</a>
</div>
{{if .LoggedIn}}
+ {{if .PendingTrust}}
+ <a href="/admin/trust" class="gf-bell" title="{{t .Lang "admin.pending_trust_tooltip"}}" aria-label="{{t .Lang "admin.pending_trust_tooltip"}}">
+ {{icon "shield"}}<span class="gf-bell-badge">{{.PendingTrust}}</span>
+ </a>
+ {{end}}
<a href="/notifications" class="gf-bell" title="{{t .Lang "nav.notifications"}}" aria-label="{{t .Lang "nav.notifications"}}">
{{icon "bell"}}{{if .PendingNotifs}}<span class="gf-bell-badge">{{.PendingNotifs}}</span>{{end}}
</a>
@@ -795,20 +801,23 @@ var shellTpl = newTpl("shell", shellSrc)
func (s *server) render(w http.ResponseWriter, r *http.Request, title, active string, body template.HTML) {
sess, loggedIn := s.currentSession(r)
- var pendingNotifs int
+ var pendingNotifs, pendingTrust int
if loggedIn {
pendingNotifs, _ = s.ops.CountPendingNotifications(sess.Principal)
+ if sess.IsAdmin {
+ pendingTrust, _ = s.ops.CountPendingTrust()
+ }
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = shellTpl.Execute(w, struct {
Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string
LoggedIn, IsAdmin bool
- PendingNotifs int
+ PendingNotifs, PendingTrust int
Body, BrandMark, IconSprite template.HTML
}{
title, s.domain, active, sess.Username, version.Version, initials(sess.Username), r.URL.Query().Get("q"),
string(s.lang(r)), r.URL.RequestURI(),
- loggedIn, sess.IsAdmin, pendingNotifs, body, template.HTML(brandMark), template.HTML(iconSprite),
+ loggedIn, sess.IsAdmin, pendingNotifs, pendingTrust, body, template.HTML(brandMark), template.HTML(iconSprite),
})
}
@@ -841,8 +850,15 @@ func flash(r *http.Request) template.HTML {
return ""
}
class := "ok"
- if r.URL.Query().Get("err") == "1" {
+ switch {
+ case r.URL.Query().Get("err") == "1":
class = "err"
+ case r.URL.Query().Get("warn") == "1":
+ // A partial success — the action itself worked, but something
+ // still needs attention (e.g. a federated grant left the
+ // collaborator's domain pending trust approval). Not a failure,
+ // so not .err — but not a plain .ok either.
+ class = "warn"
}
return template.HTML(`<div class="msg ` + class + `">` + template.HTMLEscapeString(msg) + `</div>`)
}
@@ -866,3 +882,13 @@ func redirectWithMsg(w http.ResponseWriter, r *http.Request, path, msg string, i
}
http.Redirect(w, r, path+q, http.StatusSeeOther)
}
+
+// redirectWithWarnMsg is redirectWithMsg's third state — see flash()'s
+// "warn" case.
+func redirectWithWarnMsg(w http.ResponseWriter, r *http.Request, path, msg string) {
+ sep := "?"
+ if strings.Contains(path, "?") {
+ sep = "&"
+ }
+ http.Redirect(w, r, path+sep+"msg="+template.URLQueryEscaper(msg)+"&warn=1", http.StatusSeeOther)
+}
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go
index 1d47eee..424371d 100644
--- a/internal/admin/admin.go
+++ b/internal/admin/admin.go
@@ -78,6 +78,7 @@ type Ops interface {
RevokeCollaborator(repoName, principal string) error
ListTrustedCAs() ([]store.TrustedCA, error)
+ CountPendingTrust() (int, error)
ApproveDomain(domain string) error
PinRepo(principal, domain, repo, label string) error
@@ -258,6 +259,10 @@ func (a *Admin) ListTrustedCAs() ([]store.TrustedCA, error) {
return a.Store.ListTrustedCAs()
}
+func (a *Admin) CountPendingTrust() (int, error) {
+ return a.Store.CountPendingTrust()
+}
+
func (a *Admin) ListAudit(limit int) ([]store.AuditEvent, error) {
return a.Store.ListAudit(limit)
}
internal/adminrpc/client.go
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go
index 8125be8..2943461 100644
--- a/internal/adminrpc/client.go
+++ b/internal/adminrpc/client.go
@@ -153,6 +153,12 @@ func (c *Client) ListTrustedCAs() ([]store.TrustedCA, error) {
return out.Trust, err
}
+func (c *Client) CountPendingTrust() (int, error) {
+ var out countResult
+ err := c.call(methodCountPendingTrust, nil, &out)
+ return out.Count, err
+}
+
func (c *Client) ApproveDomain(domain string) error {
err := c.call(methodApproveDomain, domainArgs{Domain: domain}, nil)
return err
internal/adminrpc/protocol.go
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go
index 63eeda6..519640c 100644
--- a/internal/adminrpc/protocol.go
+++ b/internal/adminrpc/protocol.go
@@ -28,6 +28,7 @@ const (
methodGrantCollaborator = "GrantCollaborator"
methodRevokeCollaborator = "RevokeCollaborator"
methodListTrustedCAs = "ListTrustedCAs"
+ methodCountPendingTrust = "CountPendingTrust"
methodApproveDomain = "ApproveDomain"
methodListAudit = "ListAudit"
methodSetRepoPublic = "SetRepoPublic"
internal/adminrpc/server.go
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go
index 6397b5e..1e0c966 100644
--- a/internal/adminrpc/server.go
+++ b/internal/adminrpc/server.go
@@ -155,6 +155,10 @@ func (s *Server) dispatch(req wireRequest) (any, error) {
trust, err := s.ops.ListTrustedCAs()
return listTrustResult{Trust: trust}, err
+ case methodCountPendingTrust:
+ count, err := s.ops.CountPendingTrust()
+ return countResult{Count: count}, err
+
case methodApproveDomain:
var a domainArgs
if err := json.Unmarshal(req.Args, &a); err != nil {
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go
index 5d14432..fa0321a 100644
--- a/internal/i18n/strings_en.go
+++ b/internal/i18n/strings_en.go
@@ -215,20 +215,21 @@ var en = map[string]string{
"changelog.title": "Changelog",
// ---------- admin ----------
- "admin.title": "Admin",
- "admin.card_users": "Users",
- "admin.card_users_sub": "%d admin(s), %d regular",
- "admin.card_users_link": "Manage users",
- "admin.card_trust": "Trust store",
- "admin.card_trust_pending": "pending",
- "admin.card_trust_sub": "federated domains known",
- "admin.card_trust_link": "Review trust store",
- "admin.card_audit": "Audit log",
- "admin.card_audit_sub": "most recent events on record",
- "admin.card_audit_link": "View audit log",
- "admin.recent_activity": "Recent activity",
- "admin.audit_allow": "allow",
- "admin.audit_deny": "deny",
+ "admin.title": "Admin",
+ "admin.card_users": "Users",
+ "admin.card_users_sub": "%d admin(s), %d regular",
+ "admin.card_users_link": "Manage users",
+ "admin.card_trust": "Trust store",
+ "admin.card_trust_pending": "pending",
+ "admin.card_trust_sub": "federated domains known",
+ "admin.card_trust_link": "Review trust store",
+ "admin.pending_trust_tooltip": "A federated domain is waiting for trust approval — collaborators from it can't authenticate until you review it",
+ "admin.card_audit": "Audit log",
+ "admin.card_audit_sub": "most recent events on record",
+ "admin.card_audit_link": "View audit log",
+ "admin.recent_activity": "Recent activity",
+ "admin.audit_allow": "allow",
+ "admin.audit_deny": "deny",
"admin.users_title": "Admin — Users",
"admin.users_col_username": "Username",
@@ -266,47 +267,48 @@ var en = map[string]string{
"admin.audit_note": "Showing the most recent 200 events.",
// ---------- repo ----------
- "repo.owner": "owner",
- "repo.copy_clone_url": "Copy clone URL",
- "repo.read_only": "read-only",
- "repo.https_readonly_hint": "Anonymous clone/fetch only — pushing over HTTPS is never possible, even for a public repo. Push always goes over SSH.",
- "repo.empty": "This repository is empty.",
- "repo.directory": "directory",
- "repo.file": "file",
- "repo.nothing_here": "Nothing here.",
- "repo.tags": "Tags",
- "repo.commits_title": "Commits",
- "repo.commits_empty": "No commits yet.",
- "repo.commit_label": "commit",
- "repo.parent_label": "parent",
- "repo.files_changed": "files changed",
- "repo.diff_truncated": "This commit's diff is too large to show in full — the list above still shows every changed file.",
- "repo.license": "License",
- "repo.view_file": "View file",
- "repo.binary_file": "Binary file — not shown.",
- "repo.settings_title": "settings",
- "repo.visibility_topics": "Visibility & topics",
- "repo.public_desc": "Public (readable by any authenticated principal)",
- "repo.topics_label": "Topics (comma-separated)",
- "repo.save": "Save",
- "repo.col_principal": "Principal",
- "repo.col_role": "Role",
- "repo.revoke": "Revoke",
- "repo.grant_collaborator": "Grant collaborator",
- "repo.grant": "Grant",
- "repo.grant_note": "Granting a collaborator on a domain not yet known to this instance triggers federation discovery of that domain's CA — an instance admin needs to approve it under Admin → Trust store (whitelist policy).",
- "repo.confirm_delete": "Delete",
- "repo.confirm_revoke": "Revoke access for",
- "repo.danger_zone": "Danger zone",
- "repo.danger_zone_note": "Deleting removes the repo's record from Gitfed — it does not delete the bare repo on disk.",
- "repo.collaborators_title": "Collaborators",
- "repo.federated_collaborator": "federated collaborator",
- "repo.local_collaborator": "local collaborator",
- "repo.delete_record": "Delete repo record",
- "repo.msg_saved": "saved settings",
- "repo.msg_granted": "granted %s %s",
- "repo.msg_revoked": "revoked %s",
- "repo.msg_deleted": "deleted %s",
+ "repo.owner": "owner",
+ "repo.copy_clone_url": "Copy clone URL",
+ "repo.read_only": "read-only",
+ "repo.https_readonly_hint": "Anonymous clone/fetch only — pushing over HTTPS is never possible, even for a public repo. Push always goes over SSH.",
+ "repo.empty": "This repository is empty.",
+ "repo.directory": "directory",
+ "repo.file": "file",
+ "repo.nothing_here": "Nothing here.",
+ "repo.tags": "Tags",
+ "repo.commits_title": "Commits",
+ "repo.commits_empty": "No commits yet.",
+ "repo.commit_label": "commit",
+ "repo.parent_label": "parent",
+ "repo.files_changed": "files changed",
+ "repo.diff_truncated": "This commit's diff is too large to show in full — the list above still shows every changed file.",
+ "repo.license": "License",
+ "repo.view_file": "View file",
+ "repo.binary_file": "Binary file — not shown.",
+ "repo.settings_title": "settings",
+ "repo.visibility_topics": "Visibility & topics",
+ "repo.public_desc": "Public (readable by any authenticated principal)",
+ "repo.topics_label": "Topics (comma-separated)",
+ "repo.save": "Save",
+ "repo.col_principal": "Principal",
+ "repo.col_role": "Role",
+ "repo.revoke": "Revoke",
+ "repo.grant_collaborator": "Grant collaborator",
+ "repo.grant": "Grant",
+ "repo.grant_note": "Granting a collaborator on a domain not yet known to this instance triggers federation discovery of that domain's CA — an instance admin needs to approve it under Admin → Trust store (whitelist policy).",
+ "repo.confirm_delete": "Delete",
+ "repo.confirm_revoke": "Revoke access for",
+ "repo.danger_zone": "Danger zone",
+ "repo.danger_zone_note": "Deleting removes the repo's record from Gitfed — it does not delete the bare repo on disk.",
+ "repo.collaborators_title": "Collaborators",
+ "repo.federated_collaborator": "federated collaborator",
+ "repo.local_collaborator": "local collaborator",
+ "repo.delete_record": "Delete repo record",
+ "repo.msg_saved": "saved settings",
+ "repo.msg_granted": "granted %s %s",
+ "repo.msg_granted_pending_trust": "granted %s — but %s is pending trust approval in Admin → Trust store, so they can't authenticate until you approve it",
+ "repo.msg_revoked": "revoked %s",
+ "repo.msg_deleted": "deleted %s",
// ---------- merge requests ----------
"mr.list_title": "Merge requests",
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go
index c8ef7ea..1f12201 100644
--- a/internal/i18n/strings_fr.go
+++ b/internal/i18n/strings_fr.go
@@ -215,20 +215,21 @@ var fr = map[string]string{
"changelog.title": "Changelog",
// ---------- admin ----------
- "admin.title": "Administration",
- "admin.card_users": "Utilisateurs",
- "admin.card_users_sub": "%d admin(s), %d classique(s)",
- "admin.card_users_link": "Gérer les utilisateurs",
- "admin.card_trust": "Confiance fédérée",
- "admin.card_trust_pending": "en attente",
- "admin.card_trust_sub": "domaines fédérés connus",
- "admin.card_trust_link": "Revoir la confiance fédérée",
- "admin.card_audit": "Journal d'audit",
- "admin.card_audit_sub": "événements récents enregistrés",
- "admin.card_audit_link": "Voir le journal d'audit",
- "admin.recent_activity": "Activité récente",
- "admin.audit_allow": "autorisé",
- "admin.audit_deny": "refusé",
+ "admin.title": "Administration",
+ "admin.card_users": "Utilisateurs",
+ "admin.card_users_sub": "%d admin(s), %d classique(s)",
+ "admin.card_users_link": "Gérer les utilisateurs",
+ "admin.card_trust": "Confiance fédérée",
+ "admin.card_trust_pending": "en attente",
+ "admin.card_trust_sub": "domaines fédérés connus",
+ "admin.card_trust_link": "Revoir la confiance fédérée",
+ "admin.pending_trust_tooltip": "Un domaine fédéré attend une approbation de confiance — ses collaborateurs ne peuvent pas s'authentifier tant que ce n'est pas fait",
+ "admin.card_audit": "Journal d'audit",
+ "admin.card_audit_sub": "événements récents enregistrés",
+ "admin.card_audit_link": "Voir le journal d'audit",
+ "admin.recent_activity": "Activité récente",
+ "admin.audit_allow": "autorisé",
+ "admin.audit_deny": "refusé",
"admin.users_title": "Administration — Utilisateurs",
"admin.users_col_username": "Utilisateur",
@@ -266,47 +267,48 @@ var fr = map[string]string{
"admin.audit_note": "Affichage des 200 événements les plus récents.",
// ---------- repo ----------
- "repo.owner": "propriétaire",
- "repo.copy_clone_url": "Copier l'URL de clonage",
- "repo.read_only": "lecture seule",
- "repo.https_readonly_hint": "Clone/fetch anonyme uniquement — impossible de pousser en HTTPS, même sur un dépôt public. Le push passe toujours par SSH.",
- "repo.empty": "Ce dépôt est vide.",
- "repo.directory": "dossier",
- "repo.file": "fichier",
- "repo.nothing_here": "Rien ici.",
- "repo.tags": "Tags",
- "repo.commits_title": "Commits",
- "repo.commits_empty": "Aucun commit pour le moment.",
- "repo.commit_label": "commit",
- "repo.parent_label": "parent",
- "repo.files_changed": "fichiers modifiés",
- "repo.diff_truncated": "Le diff de ce commit est trop volumineux pour être affiché en entier — la liste ci-dessus montre tout de même chaque fichier modifié.",
- "repo.license": "Licence",
- "repo.view_file": "Voir le fichier",
- "repo.binary_file": "Fichier binaire — non affiché.",
- "repo.settings_title": "paramètres",
- "repo.visibility_topics": "Visibilité et sujets",
- "repo.public_desc": "Public (lisible par tout principal authentifié)",
- "repo.topics_label": "Sujets (séparés par des virgules)",
- "repo.save": "Enregistrer",
- "repo.col_principal": "Principal",
- "repo.col_role": "Rôle",
- "repo.revoke": "Révoquer",
- "repo.grant_collaborator": "Ajouter un collaborateur",
- "repo.grant": "Accorder",
- "repo.grant_note": "Accorder un accès à un domaine encore inconnu de cette instance déclenche la découverte fédérée de la CA de ce domaine — un administrateur de l'instance doit l'approuver dans Administration → Confiance fédérée (politique de liste blanche).",
- "repo.confirm_delete": "Supprimer",
- "repo.confirm_revoke": "Révoquer l'accès de",
- "repo.danger_zone": "Zone de danger",
- "repo.danger_zone_note": "La suppression retire l'entrée du dépôt de Gitfed — elle ne supprime pas le dépôt bare sur le disque.",
- "repo.collaborators_title": "Collaborateurs",
- "repo.federated_collaborator": "collaborateur fédéré",
- "repo.local_collaborator": "collaborateur local",
- "repo.delete_record": "Supprimer l'entrée du dépôt",
- "repo.msg_saved": "paramètres enregistrés",
- "repo.msg_granted": "%s ajouté avec le rôle %s",
- "repo.msg_revoked": "%s révoqué",
- "repo.msg_deleted": "%s supprimé",
+ "repo.owner": "propriétaire",
+ "repo.copy_clone_url": "Copier l'URL de clonage",
+ "repo.read_only": "lecture seule",
+ "repo.https_readonly_hint": "Clone/fetch anonyme uniquement — impossible de pousser en HTTPS, même sur un dépôt public. Le push passe toujours par SSH.",
+ "repo.empty": "Ce dépôt est vide.",
+ "repo.directory": "dossier",
+ "repo.file": "fichier",
+ "repo.nothing_here": "Rien ici.",
+ "repo.tags": "Tags",
+ "repo.commits_title": "Commits",
+ "repo.commits_empty": "Aucun commit pour le moment.",
+ "repo.commit_label": "commit",
+ "repo.parent_label": "parent",
+ "repo.files_changed": "fichiers modifiés",
+ "repo.diff_truncated": "Le diff de ce commit est trop volumineux pour être affiché en entier — la liste ci-dessus montre tout de même chaque fichier modifié.",
+ "repo.license": "Licence",
+ "repo.view_file": "Voir le fichier",
+ "repo.binary_file": "Fichier binaire — non affiché.",
+ "repo.settings_title": "paramètres",
+ "repo.visibility_topics": "Visibilité et sujets",
+ "repo.public_desc": "Public (lisible par tout principal authentifié)",
+ "repo.topics_label": "Sujets (séparés par des virgules)",
+ "repo.save": "Enregistrer",
+ "repo.col_principal": "Principal",
+ "repo.col_role": "Rôle",
+ "repo.revoke": "Révoquer",
+ "repo.grant_collaborator": "Ajouter un collaborateur",
+ "repo.grant": "Accorder",
+ "repo.grant_note": "Accorder un accès à un domaine encore inconnu de cette instance déclenche la découverte fédérée de la CA de ce domaine — un administrateur de l'instance doit l'approuver dans Administration → Confiance fédérée (politique de liste blanche).",
+ "repo.confirm_delete": "Supprimer",
+ "repo.confirm_revoke": "Révoquer l'accès de",
+ "repo.danger_zone": "Zone de danger",
+ "repo.danger_zone_note": "La suppression retire l'entrée du dépôt de Gitfed — elle ne supprime pas le dépôt bare sur le disque.",
+ "repo.collaborators_title": "Collaborateurs",
+ "repo.federated_collaborator": "collaborateur fédéré",
+ "repo.local_collaborator": "collaborateur local",
+ "repo.delete_record": "Supprimer l'entrée du dépôt",
+ "repo.msg_saved": "paramètres enregistrés",
+ "repo.msg_granted": "%s ajouté avec le rôle %s",
+ "repo.msg_granted_pending_trust": "%s ajouté — mais %s est en attente d'approbation dans Admin → Trust store, il/elle ne pourra pas s'authentifier tant que ce n'est pas fait",
+ "repo.msg_revoked": "%s révoqué",
+ "repo.msg_deleted": "%s supprimé",
// ---------- merge requests ----------
"mr.list_title": "Merge requests",
internal/store/trust.go
diff --git a/internal/store/trust.go b/internal/store/trust.go
index 1c6ff1b..238e527 100644
--- a/internal/store/trust.go
+++ b/internal/store/trust.go
@@ -48,6 +48,23 @@ func (s *Store) ListTrustedCAs() ([]TrustedCA, error) {
return out, err
}
+// CountPendingTrust is a cheap version of ListTrustedCAs for the admin nav
+// badge (see cmd/gitfed-web/render.go) — computed on every page render for
+// a logged-in admin, so it stays a count rather than the full list.
+func (s *Store) CountPendingTrust() (int, error) {
+ all, err := s.ListTrustedCAs()
+ if err != nil {
+ return 0, err
+ }
+ n := 0
+ for _, t := range all {
+ if t.Status == TrustPending {
+ n++
+ }
+ }
+ return n, nil
+}
+
// ApproveTrustedCA flips a pending CA record to trusted.
func (s *Store) ApproveTrustedCA(domain string) error {
return s.db.Update(func(tx *bolt.Tx) error {