~gitpocket/gitpocket

feat(org): let members edit org profile, picture and membership

commit
fd16cceea0d3afdd64605a3973d82fcd4129c8c4 browse tree
parent
9a8f9e1065ff1150b89cfb086ebbb031a5a2e947
author
james <j@cpl0.zip>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-13 10:51 UTC

5 files changed, +116 −22

static/style.css modified

@@ -403,25 +403,18 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
403403 .dashhead h3{border:0;margin:0;padding:0}
404404 @media(max-width:800px){.dash{grid-template-columns:1fr}}
405405 .orghead{display:flex;align-items:flex-start;gap:1.4rem;margin:-1rem 0 1.6rem;padding:1.4rem 1.5rem;background:var(--accentsoft);border-left:4px solid var(--accent)}
406-.orghead .btn{margin-left:auto;flex:none}
406+.orgactions{margin-left:auto;flex:none;display:flex;flex-direction:column;gap:.45rem}.orgactions .btn{text-align:center}
407407 .orgmark img,.orgmark span{display:flex;width:5.5rem;height:5.5rem;border-radius:2px;object-fit:cover}
408408 .orgmark span,.people li span,.orglist span{align-items:center;justify-content:center;background:var(--accent);color:#fff;font-weight:600;text-transform:uppercase}
409409 .orgmark span{font-size:2.4rem}
410410 .orgident{min-width:0}
411-.orgident h2{display:flex;align-items:center;flex-wrap:wrap;gap:.6rem;font-size:1.7rem;margin:0}
411+.orgident h2{font-size:1.7rem;margin:0}
412412 .orgident .handle{color:var(--muted);margin:.1rem 0 .5rem}
413413 .orgident .bio{margin:0 0 .5rem;max-width:44rem}
414414 .orgident .bio p{margin:0}
415-.orgbadge{display:inline-block;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:0 .4rem;font-size:.72rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;vertical-align:middle;line-height:1.5}
416415 .facts.inline{display:flex;flex-wrap:wrap;gap:.2rem 1.2rem;margin:0}
417416 .columns.org{grid-template-columns:minmax(0,1fr) 220px;gap:2rem}
418417 .columns.org h3 .count,.people h3 .count{color:var(--muted);font-weight:400;font-size:.9rem;margin-left:.3rem}
419-.orgrepos{display:grid;grid-template-columns:repeat(auto-fill,minmax(16rem,1fr));gap:.7rem;margin-bottom:1.6rem}
420-.orgrepo{display:flex;flex-direction:column;gap:.3rem;padding:.8rem .9rem;border:1px solid var(--line);border-top:3px solid var(--accent);color:var(--fg);text-decoration:none}
421-.orgrepo:hover{background:var(--accentsoft)}
422-.orgrepo strong{color:var(--accent);font-size:1.05rem}
423-.orgrepo .desc{font-size:.93rem}
424-.orgrepo .meta{margin-top:auto;color:var(--muted);font-size:.82rem}
425418 .people ul{list-style:none;margin:0;padding:0}
426419 .people li a{display:grid;grid-template-columns:2.4rem 1fr;grid-template-rows:auto auto;column-gap:.6rem;align-items:center;padding:.35rem 0;color:var(--fg);text-decoration:none}
427420 .people li img,.people li span{grid-row:1/3;display:flex;width:2.4rem;height:2.4rem;border-radius:50%;object-fit:cover}
@@ -432,4 +425,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
432425 .orglist a{display:flex;align-items:center;gap:.5rem;color:var(--fg);margin:.3rem 0}
433426 .orglist img,.orglist span{display:flex;width:1.6rem;height:1.6rem;border-radius:2px;object-fit:cover;font-size:.8rem}
434427 .hit.org .hitavatar span{background:var(--accent);color:#fff;border-radius:2px}
435-@media (max-width:800px){.orghead{flex-wrap:wrap;padding:1rem}.orghead .btn{margin-left:0}.columns.org{grid-template-columns:1fr}}
428+@media (max-width:800px){.orghead{flex-wrap:wrap;padding:1rem}.orgactions{margin-left:0;flex-direction:row}.columns.org{grid-template-columns:1fr}}
429+.memberlist{list-style:none;margin:0 0 .8rem;padding:0;max-width:32rem}
430+.memberlist li{display:flex;align-items:center;justify-content:space-between;padding:.35rem 0;border-bottom:1px solid var(--line)}
431+.memberlist form{margin:0}

templates/org.html modified

@@ -4,7 +4,7 @@
44 <header class="orghead">
55 <div class="orgmark">{{if $p.Avatar}}<img src="/~{{$o.Name}}/avatar?v={{$p.Avatar}}" alt="">{{else}}<span>{{slice $o.Name 0 1}}</span>{{end}}</div>
66 <div class="orgident">
7-<h2>{{with $p.Display}}{{.}}{{else}}{{$o.Name}}{{end}} <span class="orgbadge">Organisation</span></h2>
7+<h2>{{with $p.Display}}{{.}}{{else}}{{$o.Name}}{{end}}</h2>
88 <p class="handle">~{{$o.Name}}</p>
99 {{with .Data.Bio}}<div class="bio readme">{{.}}</div>{{end}}
1010 <ul class="facts inline">
@@ -13,12 +13,12 @@
1313 <li title="{{date $o.Created}}"><svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3"/></svg>Since {{$o.Created.Format "January 2006"}}</li>
1414 </ul>
1515 </div>
16-{{if .Data.Member}}<a class="btn primary" href="/create?owner={{$o.Name}}">New repository</a>{{end}}
16+{{if .Data.Member}}<div class="orgactions"><a class="btn primary" href="/create?owner={{$o.Name}}">New repository</a><a class="btn" href="/~{{$o.Name}}/settings">Edit organisation</a></div>{{end}}
1717 </header>
1818 <div class="columns org">
1919 <div class="main">
2020 <h3 class="plain">Repositories <span class="count">{{len .Data.Repos}}</span></h3>
21-{{if .Data.Repos}}<div class="orgrepos">{{range .Data.Repos}}<a class="orgrepo" href="/{{.Path}}"><strong>{{.Name}}</strong>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}{{if .Description}}<span class="desc">{{.Description}}</span>{{end}}<span class="meta">Updated {{ago .Updated}}{{if .Stars}} · ☆ {{.Stars}}{{end}}</span></a>{{end}}</div>{{else}}<p class="info">This organisation does not have any repositories.</p>{{end}}
21+{{if .Data.Repos}}<div class="events">{{range .Data.Repos}}<div class="event"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}{{if .Stars}}<span class="starcount" title="{{.Stars}} stars">☆ {{.Stars}}</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">This organisation does not have any repositories.</p>{{end}}
2222 {{with .Data.Activity}}<section class="activity"><div class="heathead"><p class="muted small">{{.Total}} commit{{if ne .Total 1}}s{{end}} across the organisation {{if .Year}}in {{.Year}}{{else}}in the past 12 months{{end}}</p><nav class="years"><a href="?"{{if not .Year}} class="active"{{end}}>past 12 months</a>{{range .Years}}<a href="?year={{.}}"{{if eq . $.Data.Activity.Year}} class="active"{{end}}>{{.}}</a>{{end}}</nav></div>{{.Grid}}<p class="legend muted small">less <i class="h0"></i><i class="h1"></i><i class="h2"></i><i class="h3"></i><i class="h4"></i> more</p></section>{{end}}
2323 </div>
2424 <aside class="people">

templates/orgsettings.html added

@@ -0,0 +1,37 @@
1+{{define "content"}}
2+{{$p := .Data.Profile}}
3+{{$o := .Data.Org}}
4+<h3 class="rule">Edit <a href="/~{{$o.Name}}">~{{$o.Name}}</a></h3>
5+<form method="post" action="/settings/avatar" enctype="multipart/form-data" class="avatarform" id="avatarform">
6+<input type="hidden" name="org" value="{{$o.Name}}">
7+<div class="avatarbox">{{if $p.Avatar}}<img class="avatar" src="/~{{$o.Name}}/avatar?v={{$p.Avatar}}" alt="">{{end}}</div>
8+<div>
9+<label>Choose a picture <input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp" required></label>
10+<small>If the image is not square you can pick the crop below. It is resized to 256×256 and stored as PNG.</small>
11+<div class="cropper" hidden><canvas></canvas><input type="range" min="10" max="100" value="100"><small>Drag to move the square, scroll or slide to resize.</small></div>
12+<input type="hidden" name="x"><input type="hidden" name="y"><input type="hidden" name="size">
13+<div class="actions left"><button class="btn">Upload picture ▸</button>{{if $p.Avatar}}<button class="btn danger" formaction="/settings/avatar/delete" formnovalidate>Remove</button>{{end}}</div>
14+</div>
15+</form>
16+<script src="/static/crop.js" defer></script>
17+<h3 class="rule">Profile</h3>
18+<form method="post" action="/settings/profile" class="stack wide">
19+<input type="hidden" name="org" value="{{$o.Name}}">
20+<div class="pair">
21+<label>Handle <span class="muted">(you can't edit this)</span><input value="{{$o.Name}}" disabled></label>
22+<label>Name <input name="display" value="{{$p.Display}}" maxlength="100" placeholder="Shown on the organisation page"></label>
23+</div>
24+<div class="pair">
25+<label>Location <input name="location" value="{{$p.Location}}" maxlength="100"></label>
26+<label>URL <input name="website" value="{{$p.Website}}" maxlength="200" placeholder="https://"></label>
27+</div>
28+<label>Bio <textarea name="bio" rows="5" maxlength="4000" placeholder="Markdown supported">{{$p.Bio}}</textarea></label>
29+<div class="actions"><button class="btn primary">Save changes ▸</button></div>
30+</form>
31+<h3 class="rule">Members</h3>
32+<p class="muted small">Members can push to, configure and create repositories under ~{{$o.Name}}, and edit this page.</p>
33+<ul class="memberlist">{{range .Data.Members}}<li><a href="/~{{.Name}}">{{with .Profile.Display}}{{.}} {{end}}<span class="muted">~{{.Name}}</span></a><form method="post" action="/~{{$o.Name}}/members"><input type="hidden" name="name" value="{{.Name}}"><button class="btn danger small" name="remove" value="1">Remove</button></form></li>{{end}}</ul>
34+<form method="post" action="/~{{$o.Name}}/members" class="inline">
35+<input name="name" placeholder="username" required> <button class="btn">Add member ▸</button>
36+</form>
37+{{end}}

templates/search.html modified

@@ -24,7 +24,7 @@
2424 {{range $d.Repos}}<div class="hit"><h4><a class="owner" href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a>/<a href="/{{.Path}}">{{.Name}}</a></h4>{{with .Description}}<p>{{.}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a>{{end}}</p>{{end}}<p class="meta">{{with .Language}}<span><i class="dot" style="background:{{.Colour}}"></i>{{.Name}}</span>{{end}}{{if .Stars}}<span>☆ {{.Stars}}</span>{{end}}<span>Updated {{ago .Updated}}</span></p></div>{{else}}<p class="info">No repositories match. Try a shorter query or clear the language filter.</p>{{end}}
2525 {{else}}
2626 <div class="resulthead"><h3 class="plain">{{len $d.Users}} user{{if ne (len $d.Users) 1}}s{{end}}{{with $d.Query}} matching <q>{{.}}</q>{{end}}{{with $d.Lang}} writing {{.}}{{end}}</h3></div>
27-{{range $d.Users}}<div class="hit user{{if .Org}} org{{end}}"><a href="/~{{.Name}}" class="hitavatar">{{if .Profile.Avatar}}<img src="/~{{.Name}}/avatar?v={{.Profile.Avatar}}" alt="">{{else}}<span>{{slice .Name 0 1}}</span>{{end}}</a><div><h4><a href="/~{{.Name}}">{{with .Profile.Display}}{{.}}{{else}}~{{.Name}}{{end}}</a> <span class="muted">~{{.Name}}</span>{{if .Org}} <span class="orgbadge">Organisation</span>{{end}}</h4>{{with .Profile.Bio}}<p class="bio">{{.}}</p>{{end}}<p class="meta">{{with .Profile.Location}}<span>{{.}}</span>{{end}}<span>{{.Repos}} public repositor{{if eq .Repos 1}}y{{else}}ies{{end}}</span><span>{{if .Org}}Since{{else}}Joined{{end}} {{.Created.Format "January 2006"}}</span></p></div></div>{{else}}<p class="info">No users match.</p>{{end}}
27+{{range $d.Users}}<div class="hit user{{if .Org}} org{{end}}"><a href="/~{{.Name}}" class="hitavatar">{{if .Profile.Avatar}}<img src="/~{{.Name}}/avatar?v={{.Profile.Avatar}}" alt="">{{else}}<span>{{slice .Name 0 1}}</span>{{end}}</a><div><h4><a href="/~{{.Name}}">{{with .Profile.Display}}{{.}}{{else}}~{{.Name}}{{end}}</a> <span class="muted">~{{.Name}}</span></h4>{{with .Profile.Bio}}<p class="bio">{{.}}</p>{{end}}<p class="meta">{{with .Profile.Location}}<span>{{.}}</span>{{end}}<span>{{.Repos}} public repositor{{if eq .Repos 1}}y{{else}}ies{{end}}</span><span>{{if .Org}}Since{{else}}Joined{{end}} {{.Created.Format "January 2006"}}</span></p></div></div>{{else}}<p class="info">No users match.</p>{{end}}
2828 {{end}}
2929 </div>
3030 </div>

web.go modified

@@ -592,8 +592,25 @@ func (s *Server) endSessions(w http.ResponseWriter, r *http.Request) {
592592 http.Redirect(w, r, "/login", http.StatusSeeOther)
593593 }
594594
595-func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
595+func (s *Server) subject(w http.ResponseWriter, r *http.Request) (*User, string) {
596596 u := s.require(w, r)
597+ if u == nil {
598+ return nil, ""
599+ }
600+ name := r.FormValue("org")
601+ if name == "" {
602+ return u, "/settings"
603+ }
604+ org, err := s.store.UserByName(name)
605+ if err != nil || !s.store.IsOrg(org.ID) || !s.store.IsMember(org.ID, u.ID) {
606+ s.fail(w, r, 403, "You are not a member of that organisation")
607+ return nil, ""
608+ }
609+ return &org, "/~" + org.Name + "/settings"
610+}
611+
612+func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
613+ u, back := s.subject(w, r)
597614 if u == nil {
598615 return
599616 }
@@ -606,7 +623,7 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
606623 }
607624 p := Profile{Display: field("display", 100), Bio: field("bio", 4000), Location: field("location", 100), Workplace: field("workplace", 100), Website: field("website", 200)}
608625 if err := s.store.UpdateProfile(u.ID, p); err != nil {
609- http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
626+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
610627 return
611628 }
612629 http.Redirect(w, r, "/~"+u.Name, http.StatusSeeOther)
@@ -617,20 +634,20 @@ func (s *Server) avatarPath(id int64) string {
617634 }
618635
619636 func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
620- u := s.require(w, r)
637+ u, back := s.subject(w, r)
621638 if u == nil {
622639 return
623640 }
624641 r.Body = http.MaxBytesReader(w, r.Body, 16<<20)
625642 f, _, err := r.FormFile("avatar")
626643 if err != nil {
627- http.Redirect(w, r, "/settings?error=Choose+an+image+under+16+MiB", http.StatusSeeOther)
644+ http.Redirect(w, r, back+"?error=Choose+an+image+under+16+MiB", http.StatusSeeOther)
628645 return
629646 }
630647 defer f.Close()
631648 data, err := io.ReadAll(f)
632649 if err != nil {
633- http.Redirect(w, r, "/settings?error=Choose+an+image+under+16+MiB", http.StatusSeeOther)
650+ http.Redirect(w, r, back+"?error=Choose+an+image+under+16+MiB", http.StatusSeeOther)
634651 return
635652 }
636653 x, _ := strconv.Atoi(r.FormValue("x"))
@@ -643,7 +660,7 @@ func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
643660 }
644661 }
645662 if err != nil {
646- http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
663+ http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
647664 return
648665 }
649666 s.store.SetAvatar(u.ID, time.Now().Unix())
@@ -651,13 +668,13 @@ func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
651668 }
652669
653670 func (s *Server) deleteAvatar(w http.ResponseWriter, r *http.Request) {
654- u := s.require(w, r)
671+ u, back := s.subject(w, r)
655672 if u == nil {
656673 return
657674 }
658675 os.Remove(s.avatarPath(u.ID))
659676 s.store.SetAvatar(u.ID, 0)
660- http.Redirect(w, r, "/settings", http.StatusSeeOther)
677+ http.Redirect(w, r, back, http.StatusSeeOther)
661678 }
662679
663680 func (s *Server) serveAvatar(w http.ResponseWriter, r *http.Request, owner User) {
@@ -798,6 +815,10 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
798815 s.userFeed(w, r, owner)
799816 return
800817 }
818+ if (name == "settings" || name == "members") && len(parts) == 2 && s.store.IsOrg(owner.ID) {
819+ s.orgSettings(w, r, owner, name)
820+ return
821+ }
801822 if name == "stars" && len(parts) == 2 && r.Method == "GET" {
802823 starred, _ := s.store.StarredRepos(owner.ID)
803824 s.render(w, r, "stars", page{Title: "~" + owner.Name + " stars", User: user, Data: map[string]any{"Owner": owner, "Starred": starred, "Count": len(starred)}})
@@ -1422,3 +1443,43 @@ func humanSize(n int64) string {
14221443 return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
14231444 }
14241445 }
1446+
1447+func (s *Server) orgSettings(w http.ResponseWriter, r *http.Request, org User, action string) {
1448+ u := s.require(w, r)
1449+ if u == nil {
1450+ return
1451+ }
1452+ if !s.store.IsMember(org.ID, u.ID) {
1453+ s.fail(w, r, 403, "You are not a member of this organisation")
1454+ return
1455+ }
1456+ back := "/~" + org.Name + "/settings"
1457+ if r.Method == "POST" && action == "members" {
1458+ name := strings.TrimPrefix(strings.TrimSpace(r.FormValue("name")), "~")
1459+ target, err := s.store.UserByName(name)
1460+ switch {
1461+ case err != nil || s.store.IsOrg(target.ID):
1462+ back += "?error=No+such+user"
1463+ case r.FormValue("remove") != "":
1464+ if len(s.store.Members(org.ID)) == 1 {
1465+ back += "?error=An+organisation+needs+at+least+one+member"
1466+ } else {
1467+ s.store.SetMember(org.ID, target.ID, false)
1468+ }
1469+ default:
1470+ s.store.SetMember(org.ID, target.ID, true)
1471+ }
1472+ http.Redirect(w, r, back, http.StatusSeeOther)
1473+ return
1474+ }
1475+ if r.Method != "GET" || action != "settings" {
1476+ http.NotFound(w, r)
1477+ return
1478+ }
1479+ var people []userHit
1480+ for _, m := range s.store.Members(org.ID) {
1481+ people = append(people, userHit{User: m, Profile: s.store.Profile(m.ID)})
1482+ }
1483+ s.render(w, r, "orgsettings", page{Title: "~" + org.Name + " settings", User: u, Error: r.URL.Query().Get("error"),
1484+ Data: map[string]any{"Org": org, "Profile": s.store.Profile(org.ID), "Members": people}})
1485+}