User approval admin page and RSS
This commit is contained in:
parent
cad1c397c1
commit
f8985e6ee3
|
@ -88,6 +88,21 @@ func DeleteSession(ctx context.Context, conn *pgxpool.Pool, id string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func DeleteSessionForUser(ctx context.Context, conn *pgxpool.Pool, username string) error {
|
||||
_, err := conn.Exec(ctx,
|
||||
`
|
||||
DELETE FROM sessions
|
||||
WHERE LOWER(username) = LOWER($1)
|
||||
`,
|
||||
username,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to delete session")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewSessionCookie(session *models.Session) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
|
|
|
@ -25,6 +25,10 @@ var Config = HMNConfig{
|
|||
CookieDomain: ".handmade.local",
|
||||
CookieSecure: false,
|
||||
},
|
||||
Admin: AdminConfig {
|
||||
AtomUsername: "admin",
|
||||
AtomPassword: "password",
|
||||
},
|
||||
Email: EmailConfig{
|
||||
ServerAddress: "smtp.example.com",
|
||||
ServerPort: 587,
|
||||
|
|
|
@ -23,6 +23,7 @@ type HMNConfig struct {
|
|||
LogLevel zerolog.Level
|
||||
Postgres PostgresConfig
|
||||
Auth AuthConfig
|
||||
Admin AdminConfig
|
||||
Email EmailConfig
|
||||
DigitalOcean DigitalOceanConfig
|
||||
Discord DiscordConfig
|
||||
|
@ -82,6 +83,11 @@ type EpisodeGuide struct {
|
|||
Projects map[string]string // NOTE(asaf): Maps from slugs to default episode guide topic
|
||||
}
|
||||
|
||||
type AdminConfig struct {
|
||||
AtomUsername string
|
||||
AtomPassword string
|
||||
}
|
||||
|
||||
func init() {
|
||||
if Config.EpisodeGuide.Projects == nil {
|
||||
Config.EpisodeGuide.Projects = make(map[string]string)
|
||||
|
|
|
@ -102,6 +102,11 @@ func TestUserSettings(t *testing.T) {
|
|||
AssertRegexMatch(t, BuildUserSettings("test"), RegexUserSettings, nil)
|
||||
}
|
||||
|
||||
func TestAdmin(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildAdminAtomFeed(), RegexAdminAtomFeed, nil)
|
||||
AssertRegexMatch(t, BuildAdminApprovalQueue(), RegexAdminApprovalQueue, nil)
|
||||
}
|
||||
|
||||
func TestSnippet(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"})
|
||||
}
|
||||
|
|
|
@ -200,6 +200,24 @@ func BuildUserSettings(section string) string {
|
|||
return ProjectUrlWithFragment("/settings", nil, "", section)
|
||||
}
|
||||
|
||||
/*
|
||||
* Admin
|
||||
*/
|
||||
|
||||
var RegexAdminAtomFeed = regexp.MustCompile(`^/admin/atom$`)
|
||||
|
||||
func BuildAdminAtomFeed() string {
|
||||
defer CatchPanic()
|
||||
return Url("/admin/atom", nil)
|
||||
}
|
||||
|
||||
var RegexAdminApprovalQueue = regexp.MustCompile(`^/admin/approvals$`)
|
||||
|
||||
func BuildAdminApprovalQueue() string {
|
||||
defer CatchPanic()
|
||||
return Url("/admin/approvals", nil)
|
||||
}
|
||||
|
||||
/*
|
||||
* Snippets
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
{{ template "base.html" . }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="content-block">
|
||||
{{ range .Posts }}
|
||||
<div class="post background-even pa3">
|
||||
<div class="fl w-100 w-25-l pt3 pa3-l flex tc-l">
|
||||
<div class="fl w-20 mw3 dn-l w3">
|
||||
<!-- Mobile avatar -->
|
||||
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
|
||||
</div>
|
||||
<div class="w-100-l pl3 pl0-l flex flex-column items-center-l">
|
||||
<div>
|
||||
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a>
|
||||
</div>
|
||||
{{ if and .Author.Name (ne .Author.Name .Author.Username) }}
|
||||
<div class="c--dim f7"> {{ .Author.Name }} </div>
|
||||
{{ end }}
|
||||
<!-- Large avatar -->
|
||||
<div class="dn db-l w-60 pv2">
|
||||
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
|
||||
</div>
|
||||
<div class="i c--dim f7">
|
||||
{{ if .Author.Blurb }}
|
||||
{{ .Author.Blurb }} {{/* TODO: Linebreaks? */}}
|
||||
{{ else if .Author.Bio }}
|
||||
{{ .Author.Bio }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fl w-100 w-75-l pv3 pa3-l">
|
||||
<div class="w-100 flex-l flex-row-reverse-l">
|
||||
<div class="inline-flex flex-row-reverse pl3-l pb3 items-center">
|
||||
<div class="postid">
|
||||
<a name="{{ .ID }}" href="{{ .Url }}">#{{ .ID }}</a>
|
||||
</div>
|
||||
<div class="flex pr3">
|
||||
<form method="POST" class="mr4" target="{{ $.SubmitUrl }}">
|
||||
{{ csrftoken $.Session }}
|
||||
<input type="hidden" name="action" value="{{ $.ApprovalAction }}" />
|
||||
<input type="hidden" name="user_id" value="{{ .Author.ID }}" />
|
||||
<input type="submit" value="Approve User" />
|
||||
</form>
|
||||
<form method="POST" target="{{ $.SubmitUrl }}">
|
||||
{{ csrftoken $.Session }}
|
||||
<input type="hidden" name="action" value="{{ $.SpammerAction }}" />
|
||||
<input type="hidden" name="user_id" value="{{ .Author.ID }}" />
|
||||
<input type="submit" value="Mark as spammer" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-100 pb3">
|
||||
<div class="b" role="heading" aria-level="2">{{ .Title }}</div>
|
||||
{{ timehtml (relativedate .PostDate) .PostDate }}
|
||||
{{ if and $.User.IsStaff .IP }}
|
||||
<span>[{{ .IP }}]</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-content overflow-x-auto">
|
||||
{{ .Content }}
|
||||
</div>
|
||||
{{/* {% if post.author.signature|length %}
|
||||
<div class="signature"><hr />
|
||||
{{ post.author.signature|bbdecode|safe }}
|
||||
</div>
|
||||
{% endif %} */}}
|
||||
</div>
|
||||
<div class="cb"></div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
{{ noescape "<?xml version=\"1.0\" encoding=\"utf-8\"?>" }}
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title type="text">{{ .Title }}</title>
|
||||
<subtitle type="html">{{ .Subtitle }}</subtitle>
|
||||
<link href="{{ .HomepageUrl }}"/>
|
||||
<link rel="self" type="application/atom+xml" href="{{ .AtomFeedUrl }}"/>
|
||||
<link rel="alternate" type="text/html" hreflang="en" href="{{ .FeedUrl }}"/>
|
||||
<rights type="html">{{ .CopyrightStatement }}</rights>
|
||||
<generator uri="{{ .HomepageUrl }}" version="{{ .SiteVersion }}">Handmade Network site engine v{{ .SiteVersion }}</generator>
|
||||
<updated>{{ rfc3339 .Updated }}</updated>
|
||||
<id>{{ .FeedID }}</id>
|
||||
{{ if .Posts }}
|
||||
{{ range .Posts }}
|
||||
<entry>
|
||||
<title>{{ if .PostTypePrefix }}{{ .PostTypePrefix }}: {{ end }}{{ .Title }}</title>
|
||||
<link rel="alternate" type="text/html" href="{{ .Url }}" />
|
||||
<id>{{ .UUID }}</id>
|
||||
<published>{{ rfc3339 .Date }}</published>
|
||||
<updated>{{ rfc3339 .LastEditDate }}</updated>
|
||||
<author>
|
||||
<name>{{ .User.Name }}</name>
|
||||
<uri>{{ .User.ProfileUrl }}</uri>
|
||||
</author>
|
||||
<summary type="html">{{ .Preview }}</summary>
|
||||
</entry>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</feed>
|
|
@ -0,0 +1,286 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/auth"
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v4/pgxpool"
|
||||
)
|
||||
|
||||
type AdminAtomFeedData struct {
|
||||
Title string
|
||||
Subtitle string
|
||||
|
||||
HomepageUrl string
|
||||
AtomFeedUrl string
|
||||
FeedUrl string
|
||||
|
||||
CopyrightStatement string
|
||||
SiteVersion string
|
||||
Updated time.Time
|
||||
FeedID string
|
||||
|
||||
Posts []templates.PostListItem
|
||||
}
|
||||
|
||||
func AdminAtomFeed(c *RequestContext) ResponseData {
|
||||
creds := fmt.Sprintf("%s:%s", config.Config.Admin.AtomUsername, config.Config.Admin.AtomPassword)
|
||||
expectedAuth := fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(creds)))
|
||||
auth, hasAuth := c.Req.Header["Authorization"]
|
||||
if !hasAuth || auth[0] != expectedAuth {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
feedData := AdminAtomFeedData{
|
||||
HomepageUrl: hmnurl.BuildHomepage(),
|
||||
CopyrightStatement: fmt.Sprintf("Copyright (C) 2014-%d Handmade.Network and its contributors", time.Now().Year()),
|
||||
SiteVersion: "2.0",
|
||||
Title: "Admin feed",
|
||||
Subtitle: "Unapproved user posts",
|
||||
FeedID: uuid.NewSHA1(uuid.NameSpaceURL, []byte(hmnurl.BuildAdminAtomFeed())).URN(),
|
||||
AtomFeedUrl: hmnurl.BuildAdminAtomFeed(),
|
||||
FeedUrl: hmnurl.BuildAdminApprovalQueue(),
|
||||
}
|
||||
|
||||
unapprovedPosts, err := fetchUnapprovedPosts(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch unapproved posts"))
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
for _, post := range unapprovedPosts {
|
||||
postItem := MakePostListItem(
|
||||
lineageBuilder,
|
||||
&post.Project,
|
||||
&post.Thread,
|
||||
&post.Post,
|
||||
&post.Author,
|
||||
false,
|
||||
true,
|
||||
c.Theme,
|
||||
)
|
||||
|
||||
postItem.PostTypePrefix = fmt.Sprintf("ADMIN::UNAPPROVED: %s", postItem.PostTypePrefix)
|
||||
postItem.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(postItem.Url)).URN()
|
||||
postItem.LastEditDate = post.CurrentVersion.Date
|
||||
feedData.Posts = append(feedData.Posts, postItem)
|
||||
}
|
||||
if len(feedData.Posts) > 0 {
|
||||
feedData.Updated = feedData.Posts[0].Date
|
||||
} else {
|
||||
feedData.Updated = time.Now()
|
||||
}
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("admin_atom.xml", feedData, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
||||
const (
|
||||
ApprovalQueueActionApprove string = "approve"
|
||||
ApprovalQueueActionSpammer string = "spammer"
|
||||
)
|
||||
|
||||
type postWithTitle struct {
|
||||
templates.Post
|
||||
Title string
|
||||
}
|
||||
|
||||
type adminApprovalQueueData struct {
|
||||
templates.BaseData
|
||||
|
||||
Posts []postWithTitle
|
||||
SubmitUrl string
|
||||
ApprovalAction string
|
||||
SpammerAction string
|
||||
}
|
||||
|
||||
func AdminApprovalQueue(c *RequestContext) ResponseData {
|
||||
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
posts, err := fetchUnapprovedPosts(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch unapproved posts"))
|
||||
}
|
||||
|
||||
data := adminApprovalQueueData{
|
||||
BaseData: getBaseDataAutocrumb(c, "Admin approval queue"),
|
||||
SubmitUrl: hmnurl.BuildAdminApprovalQueue(),
|
||||
ApprovalAction: ApprovalQueueActionApprove,
|
||||
SpammerAction: ApprovalQueueActionSpammer,
|
||||
}
|
||||
for _, p := range posts {
|
||||
post := templates.PostToTemplate(&p.Post, &p.Author, c.Theme)
|
||||
post.AddContentVersion(p.CurrentVersion, &p.Author) // NOTE(asaf): Don't care about editors here
|
||||
post.Url = UrlForGenericPost(&p.Thread, &p.Post, lineageBuilder, p.Project.Slug)
|
||||
data.Posts = append(data.Posts, postWithTitle{
|
||||
Post: post,
|
||||
Title: p.Thread.Title,
|
||||
})
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("admin_approval_queue.html", data, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
||||
func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
|
||||
err := c.Req.ParseForm()
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to parse admin approval form"))
|
||||
}
|
||||
action := c.Req.Form.Get("action")
|
||||
userIdStr := c.Req.Form.Get("user_id")
|
||||
userId, err := strconv.Atoi(userIdStr)
|
||||
if err != nil {
|
||||
return RejectRequest(c, "User id can't be parsed")
|
||||
}
|
||||
|
||||
u, err := db.QueryOne(c.Context(), c.Conn, models.User{},
|
||||
`
|
||||
SELECT $columns FROM auth_user WHERE id = $1
|
||||
`,
|
||||
userId,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return RejectRequest(c, "User not found")
|
||||
} else {
|
||||
return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to fetch user"))
|
||||
}
|
||||
}
|
||||
user := u.(*models.User)
|
||||
|
||||
whatHappened := ""
|
||||
if action == ApprovalQueueActionApprove {
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
`
|
||||
UPDATE auth_user
|
||||
SET status = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
models.UserStatusApproved,
|
||||
user.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to set user to approved"))
|
||||
}
|
||||
whatHappened = fmt.Sprintf("%s approved successfully", user.Username)
|
||||
} else if action == ApprovalQueueActionSpammer {
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
`
|
||||
UPDATE auth_user
|
||||
SET status = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
models.UserStatusBanned,
|
||||
user.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to set user to banned"))
|
||||
}
|
||||
err = auth.DeleteSessionForUser(c.Context(), c.Conn, user.Username)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to log out user"))
|
||||
}
|
||||
err = deleteAllPostsForUser(c.Context(), c.Conn, user.ID)
|
||||
whatHappened = fmt.Sprintf("%s banned successfully", user.Username)
|
||||
} else {
|
||||
whatHappened = fmt.Sprintf("Unrecognized action: %s", action)
|
||||
}
|
||||
|
||||
res := c.Redirect(hmnurl.BuildAdminApprovalQueue(), http.StatusSeeOther)
|
||||
res.AddFutureNotice("success", whatHappened)
|
||||
return res
|
||||
}
|
||||
|
||||
type UnapprovedPost struct {
|
||||
Project models.Project `db:"project"`
|
||||
Thread models.Thread `db:"thread"`
|
||||
Post models.Post `db:"post"`
|
||||
CurrentVersion models.PostVersion `db:"ver"`
|
||||
Author models.User `db:"author"`
|
||||
}
|
||||
|
||||
func fetchUnapprovedPosts(c *RequestContext) ([]*UnapprovedPost, error) {
|
||||
it, err := db.Query(c.Context(), c.Conn, UnapprovedPost{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
handmade_post AS post
|
||||
JOIN handmade_project AS project ON post.project_id = project.id
|
||||
JOIN handmade_thread AS thread ON post.thread_id = thread.id
|
||||
JOIN handmade_postversion AS ver ON ver.id = post.current_id
|
||||
JOIN auth_user AS author ON author.id = post.author_id
|
||||
WHERE
|
||||
NOT thread.deleted
|
||||
AND author.status = $1
|
||||
ORDER BY post.postdate DESC
|
||||
`,
|
||||
models.UserStatusConfirmed,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch unapproved posts")
|
||||
}
|
||||
var res []*UnapprovedPost
|
||||
for _, iresult := range it.ToSlice() {
|
||||
res = append(res, iresult.(*UnapprovedPost))
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func deleteAllPostsForUser(ctx context.Context, conn *pgxpool.Pool, userId int) error {
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to start transaction")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
type toDelete struct {
|
||||
ThreadID int `db:"thread.id"`
|
||||
PostID int `db:"post.id"`
|
||||
}
|
||||
it, err := db.Query(ctx, tx, toDelete{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
handmade_post as post
|
||||
JOIN handmade_thread AS thread ON post.thread_id = thread.id
|
||||
JOIN auth_user AS author ON author.id = post.author_id
|
||||
WHERE author.id = $1
|
||||
`,
|
||||
userId,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to fetch posts to delete for user")
|
||||
}
|
||||
|
||||
for _, iResult := range it.ToSlice() {
|
||||
row := iResult.(*toDelete)
|
||||
DeletePost(ctx, tx, row.ThreadID, row.PostID)
|
||||
}
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to commit transaction")
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -372,8 +372,7 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
|||
SET status = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
// models.UserStatusConfirmed,
|
||||
models.UserStatusApproved, // TODO: Change this back to Confirmed after the jam.
|
||||
models.UserStatusConfirmed,
|
||||
validationResult.User.ID,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
|
@ -103,6 +103,16 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
|||
}
|
||||
}
|
||||
|
||||
adminMiddleware := func(h Handler) Handler {
|
||||
return func(c *RequestContext) (res ResponseData) {
|
||||
if c.CurrentUser == nil || !c.CurrentUser.IsStaff {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
csrfMiddleware := func(h Handler) Handler {
|
||||
// CSRF mitigation actions per the OWASP cheat sheet:
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|
||||
|
@ -183,6 +193,10 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
|||
mainRoutes.GET(hmnurl.RegexDoPasswordReset, DoPasswordReset)
|
||||
mainRoutes.POST(hmnurl.RegexDoPasswordReset, DoPasswordResetSubmit)
|
||||
|
||||
mainRoutes.GET(hmnurl.RegexAdminAtomFeed, AdminAtomFeed)
|
||||
mainRoutes.GET(hmnurl.RegexAdminApprovalQueue, adminMiddleware(AdminApprovalQueue))
|
||||
mainRoutes.POST(hmnurl.RegexAdminApprovalQueue, adminMiddleware(csrfMiddleware(AdminApprovalQueueSubmit)))
|
||||
|
||||
mainRoutes.GET(hmnurl.RegexFeed, Feed)
|
||||
mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed)
|
||||
mainRoutes.GET(hmnurl.RegexShowcase, Showcase)
|
||||
|
@ -316,7 +330,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
|||
|
||||
IsProjectPage: !c.CurrentProject.IsHMN(),
|
||||
Header: templates.Header{
|
||||
AdminUrl: hmnurl.BuildHomepage(), // TODO(asaf)
|
||||
AdminUrl: hmnurl.BuildAdminApprovalQueue(), // TODO(asaf): Replace with general-purpose admin page
|
||||
UserSettingsUrl: hmnurl.BuildUserSettings(""),
|
||||
LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()),
|
||||
LogoutActionUrl: hmnurl.BuildLogoutAction(c.FullUrl()),
|
||||
|
|
Loading…
Reference in New Issue