Gitfed
bastien-mrq/gitfed/ Commits/ e7a77a8

Redesign settings/repo-settings, add missing delete confirmations

Brings both settings pages up to the visual language established elsewhere this session (icons, list-row components, card-based layout instead of bare tables) — approved from an Artifact mockup first. Also adds the two destructive actions that had no confirmation prompt: revoking a collaborator and removing an SSH key (warns specifically about losing SSH access if it's your last one).

bastien-mrq 2026-07-28 20:56 commit e7a77a83b1580e1cb8f35abdbe9aea8b3ae0ce30 parent 7cd1c1ce92d12ca4e0d40b8a4d7669f0f3659dd5
6 files changed +176 −106
M CHANGELOG.md +5 −0
M cmd/gitfed-web/handlers_repo.go +46 −24
M cmd/gitfed-web/handlers_settings.go +31 −16
M cmd/gitfed-web/render.go +20 −4
M internal/i18n/strings_en.go +37 −31
M internal/i18n/strings_fr.go +37 −31
CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb6ec3..c665e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.9.1 + +- Settings and repo-settings visually brought up to date with the rest of the app: icons on the settings tabs, a real profile card, collaborators shown as icon+role rows instead of a bare table (with a shield for the owner, a globe for federated collaborators), and a visually distinct danger zone. +- Added confirmation prompts to two destructive actions that didn't have one: revoking a collaborator, and removing an SSH key (the latter warns if it might be your only one). + ## 0.9.0 - **Pinned remote repos**: bookmark a repo on another instance from your dashboard — works instantly for public repos (no login needed there), and just gives a link for a private one you already have git access to.
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index c6237e5..182d577 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/cmd/gitfed-web/handlers_repo.go @@ -71,6 +71,14 @@ type treeEntryView struct { FullPath string } +// collaboratorView adds display-only context to a store.Collaborator: only +// the settings page needs to distinguish local from federated principals, +// so it's computed there rather than carried on the ACL model itself. +type collaboratorView struct { + store.Collaborator + Remote bool +} + // repoTpl renders the repo's file tree at the current path, GitLab-style: // files/dirs at the top, README (and, root only, LICENSE) rendered directly // below — no separate "browse files" page. @@ -339,8 +347,8 @@ var plainTpl = template.Must(template.New("plain").Parse(`<pre>{{.}}</pre>`)) var repoSettingsTpl = newTpl("repo-settings", ` {{.Flash}} -<p><a href="/r/{{.Repo}}">&larr; {{.Repo}}</a></p> -<h1>{{.Repo}} {{t .Lang "repo.settings_title"}}</h1> +<div class="gf-crumbs"><a href="/r/{{.Repo}}">{{.Repo}}</a></div> +<div class="gf-page-head"><h1>{{t .Lang "repo.settings_title"}}</h1></div> <form class="card" method="post" action="/repo-settings/{{.Repo}}"> <strong>{{t .Lang "repo.visibility_topics"}}</strong> @@ -350,26 +358,31 @@ var repoSettingsTpl = newTpl("repo-settings", ` <button type="submit">{{t .Lang "repo.save"}}</button> </form> -<table> -<tr><th>{{t .Lang "repo.col_principal"}}</th><th>{{t .Lang "repo.col_role"}}</th><th></th></tr> -<tr> - <td>{{.Owner}}</td> - <td>{{t .Lang "role.admin"}}</td> - <td class="muted">{{t .Lang "role.owner"}}</td> -</tr> +<h3 style="font-size:0.95rem; margin-bottom:0.6rem;">{{t .Lang "repo.collaborators_title"}}</h3> +<div class="gf-card"> + <div class="gf-list-row"> + <div class="gf-list-icon">{{icon "shield"}}</div> + <div class="gf-list-main"> + <div class="primary">{{.Owner}}</div> + <div class="meta">{{t .Lang "role.owner"}}</div> + </div> + <span class="badge plain">{{t .Lang "role.admin"}}</span> + </div> {{range .Collaborators}} -<tr> - <td>{{.Principal}}</td> - <td>{{roleLabel $.Lang (print .Role)}}</td> - <td> - <form class="inline" method="post" action="/repo-revoke/{{$.Repo}}"> + <div class="gf-list-row"> + <div class="gf-list-icon">{{if .Remote}}{{icon "network"}}{{else}}{{icon "user"}}{{end}}</div> + <div class="gf-list-main"> + <div class="primary">{{.Principal}}</div> + <div class="meta">{{if .Remote}}{{t $.Lang "repo.federated_collaborator"}}{{else}}{{t $.Lang "repo.local_collaborator"}}{{end}}</div> + </div> + <span class="badge plain">{{roleLabel $.Lang (print .Role)}}</span> + <form class="inline" method="post" action="/repo-revoke/{{$.Repo}}" data-confirm="{{t $.Lang "repo.confirm_revoke"}} {{.Principal}}?"> <input type="hidden" name="principal" value="{{.Principal}}"> - <button class="danger" type="submit">{{t $.Lang "repo.revoke"}}</button> + <button class="linklike" type="submit" title="{{t $.Lang "repo.revoke"}}" aria-label="{{t $.Lang "repo.revoke"}}">{{icon "close"}}</button> </form> - </td> -</tr> + </div> {{end}} -</table> +</div> <form class="card" method="post" action="/repo-grant/{{.Repo}}"> <strong>{{t .Lang "repo.grant_collaborator"}}</strong> @@ -383,12 +396,15 @@ var repoSettingsTpl = newTpl("repo-settings", ` </select> <button type="submit">{{t .Lang "repo.grant"}}</button> </form> -<p class="muted">{{t .Lang "repo.grant_note"}}</p> +<p class="muted" style="margin-top:-0.9rem;">{{t .Lang "repo.grant_note"}}</p> -<form class="card" method="post" action="/repo-delete/{{.Repo}}" data-confirm="{{t .Lang "repo.confirm_delete"}} {{.Repo}}?"> +<div class="gf-danger-zone"> <strong>{{t .Lang "repo.danger_zone"}}</strong> - <button class="danger" type="submit">{{t .Lang "repo.delete_record"}}</button> -</form> + <p class="muted">{{t .Lang "repo.danger_zone_note"}}</p> + <form method="post" action="/repo-delete/{{.Repo}}" data-confirm="{{t .Lang "repo.confirm_delete"}} {{.Repo}}?"> + <button class="danger" type="submit">{{t .Lang "repo.delete_record"}}</button> + </form> +</div> `) func (s *server) handleRepoSettingsForm(w http.ResponseWriter, r *http.Request) { @@ -409,6 +425,12 @@ func (s *server) handleRepoSettingsForm(w http.ResponseWriter, r *http.Request) return } + collaborators := make([]collaboratorView, len(acl.Collaborators)) + for i, c := range acl.Collaborators { + _, domain, _ := strings.Cut(c.Principal, "@") + collaborators[i] = collaboratorView{Collaborator: c, Remote: domain != s.domain} + } + var buf bytes.Buffer _ = repoSettingsTpl.Execute(&buf, struct { Repo string @@ -416,9 +438,9 @@ func (s *server) handleRepoSettingsForm(w http.ResponseWriter, r *http.Request) Lang string Public bool TopicsCSV string - Collaborators []store.Collaborator + Collaborators []collaboratorView Flash template.HTML - }{name, repo.Owner, string(lang), repo.Public, strings.Join(repo.Topics, ", "), acl.Collaborators, flash(r)}) + }{name, repo.Owner, string(lang), repo.Public, strings.Join(repo.Topics, ", "), collaborators, flash(r)}) s.render(w, r, name+" "+i18n.T(lang, "repo.settings_title"), "home", template.HTML(buf.String())) }
cmd/gitfed-web/handlers_settings.go
diff --git a/cmd/gitfed-web/handlers_settings.go b/cmd/gitfed-web/handlers_settings.go index 78f65eb..2be8999 100644 --- a/cmd/gitfed-web/handlers_settings.go +++ b/cmd/gitfed-web/handlers_settings.go @@ -4,6 +4,7 @@ import ( "bytes" "html/template" "net/http" + "strings" "gitfed/internal/i18n" ) @@ -13,28 +14,38 @@ var settingsTpl = newTpl("settings", ` <div class="gf-page-head"><h1>{{t .Lang "settings.title"}}</h1></div> <div class="gf-tabs"> - <button type="button" data-tab="tab-profile" class="{{if eq .ActiveTab "profile"}}active{{end}}">{{t .Lang "settings.tab_profile"}}</button> - <button type="button" data-tab="tab-keys" class="{{if eq .ActiveTab "keys"}}active{{end}}">{{t .Lang "settings.tab_keys"}}</button> - <button type="button" data-tab="tab-password" class="{{if eq .ActiveTab "password"}}active{{end}}">{{t .Lang "settings.tab_password"}}</button> + <button type="button" data-tab="tab-profile" class="{{if eq .ActiveTab "profile"}}active{{end}}">{{icon "user"}} {{t .Lang "settings.tab_profile"}}</button> + <button type="button" data-tab="tab-keys" class="{{if eq .ActiveTab "keys"}}active{{end}}">{{icon "key"}} {{t .Lang "settings.tab_keys"}}</button> + <button type="button" data-tab="tab-password" class="{{if eq .ActiveTab "password"}}active{{end}}">{{icon "lock"}} {{t .Lang "settings.tab_password"}}</button> </div> <div id="tab-profile" class="gf-tabpane {{if eq .ActiveTab "profile"}}active{{end}}"> - <p class="muted">{{t .Lang "settings.logged_in_as"}} <strong>{{.Username}}</strong>{{if .IsAdmin}} — {{t .Lang "role.admin"}}{{end}}.</p> + <div class="gf-card gf-profile-card"> + <div class="avatar">{{.Initials}}</div> + <div> + <div class="who">{{.Username}}{{if .IsAdmin}} — <span class="admin-tag">{{t .Lang "role.admin"}}</span>{{end}}</div> + <div class="principal">{{.Principal}}</div> + </div> + </div> <p class="muted">{{t .Lang "settings.profile_note"}}</p> </div> <div id="tab-keys" class="gf-tabpane {{if eq .ActiveTab "keys"}}active{{end}}"> <div class="gf-card"> {{range .Keys}} - <div class="gf-key-row"> - <code>{{.Short}}</code> - <form class="inline" method="post" action="/settings/keys/remove"> + <div class="gf-list-row"> + <div class="gf-list-icon">{{icon "key"}}</div> + <div class="gf-list-main"> + <div class="primary">{{.Short}}</div> + <div class="meta">{{.Algo}}</div> + </div> + <form class="inline" method="post" action="/settings/keys/remove" data-confirm="{{t $.Lang "settings.confirm_remove_key"}}"> <input type="hidden" name="key" value="{{.Full}}"> <button class="danger" type="submit">{{t $.Lang "settings.remove"}}</button> </form> </div> {{else}} - <div class="gf-key-row"><span class="muted">{{t .Lang "settings.no_keys"}}</span></div> + <div class="gf-list-row"><span class="muted">{{t .Lang "settings.no_keys"}}</span></div> {{end}} </div> @@ -58,7 +69,7 @@ var settingsTpl = newTpl("settings", ` </div> `) -type keyView struct{ Short, Full string } +type keyView struct{ Short, Full, Algo string } func settingsTab(r *http.Request) string { switch r.URL.Query().Get("tab") { @@ -82,20 +93,24 @@ func (s *server) handleSettings(w http.ResponseWriter, r *http.Request) { keys := make([]keyView, len(user.PubKeys)) for i, k := range user.PubKeys { + algo, _, ok := strings.Cut(k, " ") + if !ok { + algo = "?" + } short := k if len(short) > 60 { - short = short[:60] + "..." + short = short[:60] + "…" } - keys[i] = keyView{Short: short, Full: k} + keys[i] = keyView{Short: short, Full: k, Algo: algo} } var buf bytes.Buffer _ = settingsTpl.Execute(&buf, struct { - Username, ActiveTab, Lang string - IsAdmin bool - Keys []keyView - Flash template.HTML - }{sess.Username, settingsTab(r), string(lang), sess.IsAdmin, keys, flash(r)}) + Username, Principal, ActiveTab, Lang, Initials string + IsAdmin bool + Keys []keyView + Flash template.HTML + }{sess.Username, sess.Principal, settingsTab(r), string(lang), initials(sess.Username), sess.IsAdmin, keys, flash(r)}) s.render(w, r, i18n.T(lang, "settings.title"), "settings", template.HTML(buf.String())) }
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index 03d5695..6e138e8 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -141,6 +141,7 @@ const iconSprite = `<svg width="0" height="0" style="position:absolute" aria-hid <symbol id="ic-close" viewBox="0 0 24 24"><path d="M5.5 5.5l13 13M18.5 5.5l-13 13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol> <symbol id="ic-bell" viewBox="0 0 24 24"><path d="M6 10.5c0-3.3 2.7-6 6-6s6 2.7 6 6v3.3l1.6 2.7H4.4L6 13.8V10.5Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M9.8 18.5a2.3 2.3 0 0 0 4.4 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol> <symbol id="ic-history" viewBox="0 0 24 24"><circle cx="12" cy="12.5" r="8" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12 8v4.7l3.3 2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 3.3 5 6M16 3.3 19 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol> +<symbol id="ic-user" viewBox="0 0 24 24"><circle cx="12" cy="8.3" r="3.3" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M4.8 19c1-3.2 3.9-5 7.2-5s6.2 1.8 7.2 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol> </defs> </svg>` @@ -388,10 +389,25 @@ const shellHeadSrc = `<!doctype html> .gf-tabpane { display: none; } .gf-tabpane.active { display: block; } - .gf-key-row { display: flex; align-items: center; gap: 0.9rem; padding: 0.8rem 1.1rem; border-bottom: 1px solid var(--border); } - .gf-key-row:last-child { border-bottom: none; } - .gf-key-row code { font-size: 0.82rem; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; background: none; padding: 0; } - .gf-key-row button { margin: 0; flex-shrink: 0; } + .gf-profile-card { display: flex; align-items: center; gap: 0.9rem; padding: 1.1rem; } + .gf-profile-card .avatar { width: 46px; height: 46px; border-radius: 50%; background: var(--accent-dim); color: var(--text); display: flex; align-items: center; justify-content: center; font-size: 0.95rem; font-weight: 700; flex-shrink: 0; } + .gf-profile-card .who { font-size: 1.02rem; font-weight: 700; } + .gf-profile-card .who .admin-tag { color: var(--accent); font-weight: 600; } + .gf-profile-card .principal { font-family: var(--mono); font-size: 0.8rem; color: var(--text-faint); margin-top: 0.15rem; } + + .gf-list-row { display: flex; align-items: center; gap: 0.9rem; padding: 0.85rem 1.1rem; border-bottom: 1px solid var(--border); } + .gf-list-row:last-child { border-bottom: none; } + .gf-list-row:hover { background: var(--surface-2); } + .gf-list-icon { width: 30px; height: 30px; border-radius: 7px; background: var(--surface-2); display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--text-dim); } + .gf-list-main { flex: 1; min-width: 0; } + .gf-list-main .primary { font-family: var(--mono); font-size: 0.86rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .gf-list-main .meta { font-size: 0.76rem; color: var(--text-faint); margin-top: 0.15rem; } + .gf-list-row button { margin: 0; flex-shrink: 0; } + + .gf-danger-zone { border: 1px solid rgba(242,139,130,0.35); background: rgba(242,139,130,0.05); border-radius: 10px; padding: 1.1rem; margin: 1rem 0; } + .gf-danger-zone strong { color: var(--danger-fg); font-size: 0.94rem; } + .gf-danger-zone p { margin: 0.4rem 0 0; } + .gf-danger-zone button { margin-top: 0.8rem; } .gf-admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } .gf-admin-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.1rem; display: flex; flex-direction: column; gap: 0.5rem; }
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go index 501c009..4a33b74 100644 --- a/internal/i18n/strings_en.go +++ b/internal/i18n/strings_en.go @@ -160,6 +160,7 @@ var en = map[string]string{ "settings.logged_in_as": "Logged in as", "settings.profile_note": "This password only signs you into the web UI. Git push/pull always goes over SSH with a key, independently of it.", "settings.remove": "Remove", + "settings.confirm_remove_key": "Remove this key? If it's your only one, you'll lose SSH/git access until you add another.", "settings.no_keys": "No keys yet.", "settings.add_key": "Add SSH key", "settings.pubkey_label": "Public key (authorized_keys format)", @@ -240,35 +241,40 @@ 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.license": "License", - "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.danger_zone": "Danger zone", - "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.license": "License", + "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", }
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go index 276b673..f9e99ff 100644 --- a/internal/i18n/strings_fr.go +++ b/internal/i18n/strings_fr.go @@ -160,6 +160,7 @@ var fr = map[string]string{ "settings.logged_in_as": "Connecté en tant que", "settings.profile_note": "Ce mot de passe ne sert qu'à se connecter à l'interface web. Le push/pull git passe toujours par SSH avec une clé, indépendamment de lui.", "settings.remove": "Retirer", + "settings.confirm_remove_key": "Retirer cette clé ? Si c'est la seule, vous perdrez l'accès SSH/git jusqu'à en ajouter une autre.", "settings.no_keys": "Aucune clé pour le moment.", "settings.add_key": "Ajouter une clé SSH", "settings.pubkey_label": "Clé publique (format authorized_keys)", @@ -240,35 +241,40 @@ 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.license": "Licence", - "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.danger_zone": "Zone de danger", - "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.license": "Licence", + "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é", }