aliases: add overview and revoke handlers
This commit is contained in:
parent
9d60d09843
commit
50e4ebbaca
|
@ -245,6 +245,7 @@ func runroomsrv() error {
|
|||
},
|
||||
roomsrv.StateManager,
|
||||
handlers.Databases{
|
||||
Aliases: db.Aliases,
|
||||
AuthWithSSB: db.AuthWithSSB,
|
||||
AuthFallback: db.AuthFallback,
|
||||
AllowList: db.AllowList,
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"go.mindeco.de/http/render"
|
||||
|
||||
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
|
||||
weberrors "github.com/ssb-ngi-pointer/go-ssb-room/web/errors"
|
||||
)
|
||||
|
||||
type aliasesHandler struct {
|
||||
r *render.Renderer
|
||||
|
||||
db roomdb.AliasService
|
||||
}
|
||||
|
||||
const redirectToAliases = "/admin/aliases"
|
||||
|
||||
func (h aliasesHandler) overview(rw http.ResponseWriter, req *http.Request) (interface{}, error) {
|
||||
lst, err := h.db.List(req.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reverse the slice to provide recent-to-oldest results
|
||||
for i, j := 0, len(lst)-1; i < j; i, j = i+1, j-1 {
|
||||
lst[i], lst[j] = lst[j], lst[i]
|
||||
}
|
||||
|
||||
pageData, err := paginate(lst, len(lst), req.URL.Query())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pageData, nil
|
||||
}
|
||||
|
||||
func (h aliasesHandler) revokeConfirm(rw http.ResponseWriter, req *http.Request) (interface{}, error) {
|
||||
if req.Method != "GET" {
|
||||
return nil, weberrors.ErrBadRequest{Where: "HTTP Method", Details: fmt.Errorf("expected GET request")}
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(req.URL.Query().Get("id"), 10, 64)
|
||||
if err != nil {
|
||||
err = weberrors.ErrBadRequest{Where: "ID", Details: err}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry, err := h.db.GetByID(req.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, roomdb.ErrNotFound) {
|
||||
http.Redirect(rw, req, redirectToAliases, http.StatusFound)
|
||||
return nil, ErrRedirected
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"Entry": entry,
|
||||
csrf.TemplateTag: csrf.TemplateField(req),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h aliasesHandler) revoke(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != "POST" {
|
||||
err := weberrors.ErrBadRequest{Where: "HTTP Method", Details: fmt.Errorf("expected POST request")}
|
||||
h.r.Error(rw, req, http.StatusMethodNotAllowed, err)
|
||||
return
|
||||
}
|
||||
|
||||
err := req.ParseForm()
|
||||
if err != nil {
|
||||
err = weberrors.ErrBadRequest{Where: "Form data", Details: err}
|
||||
http.Redirect(rw, req, redirectToAliases, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
status := http.StatusFound
|
||||
err = h.db.Revoke(req.Context(), req.FormValue("name"))
|
||||
if err != nil {
|
||||
if !errors.Is(err, roomdb.ErrNotFound) {
|
||||
|
||||
h.r.Error(rw, req, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
|
||||
http.Redirect(rw, req, redirectToAliases, status)
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
|
||||
"github.com/ssb-ngi-pointer/go-ssb-room/web"
|
||||
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
|
||||
"github.com/ssb-ngi-pointer/go-ssb-room/web/webassert"
|
||||
refs "go.mindeco.de/ssb-refs"
|
||||
)
|
||||
|
||||
func TestAliasesOverview(t *testing.T) {
|
||||
ts := newSession(t)
|
||||
a := assert.New(t)
|
||||
|
||||
lst := []roomdb.Alias{
|
||||
{ID: 1, Name: "alice", Feed: refs.FeedRef{ID: bytes.Repeat([]byte{0}, 32), Algo: "fake"}},
|
||||
{ID: 2, Name: "bob", Feed: refs.FeedRef{ID: bytes.Repeat([]byte("1312"), 8), Algo: "test"}},
|
||||
{ID: 3, Name: "cleo", Feed: refs.FeedRef{ID: bytes.Repeat([]byte("acab"), 8), Algo: "true"}},
|
||||
}
|
||||
ts.Aliases.ListReturns(lst, nil)
|
||||
|
||||
html, resp := ts.Client.GetHTML("/aliases")
|
||||
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
|
||||
|
||||
webassert.Localized(t, html, []webassert.LocalizedElement{
|
||||
{"#welcome", "AdminAliasesWelcome"},
|
||||
{"title", "AdminAliasesTitle"},
|
||||
{"#aliasCount", "ListCountPlural"},
|
||||
})
|
||||
|
||||
a.EqualValues(html.Find("#theList li").Length(), 3)
|
||||
|
||||
lst = []roomdb.Alias{
|
||||
{ID: 666, Name: "dave", Feed: refs.FeedRef{ID: bytes.Repeat([]byte{1}, 32), Algo: "one"}},
|
||||
}
|
||||
ts.Aliases.ListReturns(lst, nil)
|
||||
|
||||
html, resp = ts.Client.GetHTML("/aliases")
|
||||
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
|
||||
|
||||
webassert.Localized(t, html, []webassert.LocalizedElement{
|
||||
{"#welcome", "AdminAliasesWelcome"},
|
||||
{"title", "AdminAliasesTitle"},
|
||||
{"#aliasCount", "ListCountSingular"},
|
||||
})
|
||||
|
||||
elems := html.Find("#theList li")
|
||||
a.EqualValues(elems.Length(), 1)
|
||||
|
||||
// check for link to Revoke confirm link
|
||||
link, yes := elems.ContentsFiltered("a").Attr("href")
|
||||
a.True(yes, "a-tag has href attribute")
|
||||
a.Equal("/admin/aliases/revoke/confirm?id=666", link)
|
||||
}
|
||||
|
||||
func TestAliasesRevokeConfirmation(t *testing.T) {
|
||||
ts := newSession(t)
|
||||
a := assert.New(t)
|
||||
|
||||
testKey, err := refs.ParseFeedRef("@x7iOLUcq3o+sjGeAnipvWeGzfuYgrXl8L4LYlxIhwDc=.ed25519")
|
||||
a.NoError(err)
|
||||
testEntry := roomdb.Alias{ID: 666, Name: "the-test-name", Feed: *testKey}
|
||||
ts.Aliases.GetByIDReturns(testEntry, nil)
|
||||
|
||||
urlTo := web.NewURLTo(ts.Router)
|
||||
urlRevokeConfirm := urlTo(router.AdminAliasesRevokeConfirm, "id", 3)
|
||||
|
||||
html, resp := ts.Client.GetHTML(urlRevokeConfirm.String())
|
||||
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
|
||||
|
||||
a.Equal(testKey.Ref(), html.Find("pre#verify").Text(), "has the key for verification")
|
||||
|
||||
form := html.Find("form#confirm")
|
||||
|
||||
method, ok := form.Attr("method")
|
||||
a.True(ok, "form has method set")
|
||||
a.Equal("POST", method)
|
||||
|
||||
action, ok := form.Attr("action")
|
||||
a.True(ok, "form has action set")
|
||||
|
||||
addURL, err := ts.Router.Get(router.AdminAliasesRevoke).URL()
|
||||
a.NoError(err)
|
||||
|
||||
a.Equal(addURL.String(), action)
|
||||
|
||||
webassert.InputsInForm(t, form, []webassert.InputElement{
|
||||
{Name: "name", Type: "hidden", Value: testEntry.Name},
|
||||
})
|
||||
}
|
||||
|
||||
func TestAliasesRevoke(t *testing.T) {
|
||||
ts := newSession(t)
|
||||
a := assert.New(t)
|
||||
|
||||
urlTo := web.NewURLTo(ts.Router)
|
||||
urlRevoke := urlTo(router.AdminAliasesRevoke)
|
||||
|
||||
ts.Aliases.RevokeReturns(nil)
|
||||
|
||||
addVals := url.Values{"name": []string{"the-name"}}
|
||||
rec := ts.Client.PostForm(urlRevoke.String(), addVals)
|
||||
a.Equal(http.StatusFound, rec.Code)
|
||||
|
||||
a.Equal(1, ts.Aliases.RevokeCallCount())
|
||||
_, theName := ts.Aliases.RevokeArgsForCall(0)
|
||||
a.EqualValues("the-name", theName)
|
||||
|
||||
// now for unknown ID
|
||||
ts.Aliases.RevokeReturns(roomdb.ErrNotFound)
|
||||
addVals = url.Values{"name": []string{"nope"}}
|
||||
rec = ts.Client.PostForm(urlRevoke.String(), addVals)
|
||||
a.Equal(http.StatusNotFound, rec.Code)
|
||||
//TODO: update redirect code with flash errors
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
|
|
|
@ -27,6 +27,7 @@ type testSession struct {
|
|||
Client *tester.Tester
|
||||
Router *mux.Router
|
||||
|
||||
Aliases *mockdb.FakeAliasService
|
||||
AllowListDB *mockdb.FakeAllowListService
|
||||
PinnedDB *mockdb.FakePinnedNoticesService
|
||||
NoticeDB *mockdb.FakeNoticesService
|
||||
|
@ -43,6 +44,7 @@ func newSession(t *testing.T) *testSession {
|
|||
var ts testSession
|
||||
|
||||
// fake dbs
|
||||
ts.Aliases = new(mockdb.FakeAliasService)
|
||||
ts.AllowListDB = new(mockdb.FakeAllowListService)
|
||||
ts.PinnedDB = new(mockdb.FakePinnedNoticesService)
|
||||
ts.NoticeDB = new(mockdb.FakeNoticesService)
|
||||
|
@ -97,6 +99,7 @@ func newSession(t *testing.T) *testSession {
|
|||
r,
|
||||
ts.RoomState,
|
||||
Databases{
|
||||
Aliases: ts.Aliases,
|
||||
AllowList: ts.AllowListDB,
|
||||
Invites: ts.InvitesDB,
|
||||
Notices: ts.NoticeDB,
|
||||
|
|
|
@ -22,6 +22,9 @@ var HTMLTemplates = []string{
|
|||
"admin/dashboard.tmpl",
|
||||
"admin/menu.tmpl",
|
||||
|
||||
"admin/aliases.tmpl",
|
||||
"admin/aliases-revoke-confirm.tmpl",
|
||||
|
||||
"admin/allow-list.tmpl",
|
||||
"admin/allow-list-remove-confirm.tmpl",
|
||||
|
||||
|
@ -34,6 +37,7 @@ var HTMLTemplates = []string{
|
|||
|
||||
// Databases is an option struct that encapsualtes the required database services
|
||||
type Databases struct {
|
||||
Aliases roomdb.AliasService
|
||||
AllowList roomdb.AllowListService
|
||||
Invites roomdb.InviteService
|
||||
Notices roomdb.NoticesService
|
||||
|
@ -62,14 +66,22 @@ func Handler(
|
|||
return map[string]interface{}{}, nil
|
||||
}))
|
||||
|
||||
var ah = allowListHandler{
|
||||
var ah = aliasesHandler{
|
||||
r: r,
|
||||
db: dbs.Aliases,
|
||||
}
|
||||
mux.HandleFunc("/aliases", r.HTML("admin/aliases.tmpl", ah.overview))
|
||||
mux.HandleFunc("/aliases/revoke/confirm", r.HTML("admin/aliases-revoke-confirm.tmpl", ah.revokeConfirm))
|
||||
mux.HandleFunc("/aliases/revoke", ah.revoke)
|
||||
|
||||
var mh = allowListHandler{
|
||||
r: r,
|
||||
al: dbs.AllowList,
|
||||
}
|
||||
mux.HandleFunc("/members", r.HTML("admin/allow-list.tmpl", ah.overview))
|
||||
mux.HandleFunc("/members/add", ah.add)
|
||||
mux.HandleFunc("/members/remove/confirm", r.HTML("admin/allow-list-remove-confirm.tmpl", ah.removeConfirm))
|
||||
mux.HandleFunc("/members/remove", ah.remove)
|
||||
mux.HandleFunc("/members", r.HTML("admin/allow-list.tmpl", mh.overview))
|
||||
mux.HandleFunc("/members/add", mh.add)
|
||||
mux.HandleFunc("/members/remove/confirm", r.HTML("admin/allow-list-remove-confirm.tmpl", mh.removeConfirm))
|
||||
mux.HandleFunc("/members/remove", mh.remove)
|
||||
|
||||
var ih = invitesHandler{
|
||||
r: r,
|
||||
|
|
|
@ -19,8 +19,8 @@ import (
|
|||
"go.mindeco.de/logging"
|
||||
"golang.org/x/crypto/ed25519"
|
||||
|
||||
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
|
||||
"github.com/ssb-ngi-pointer/go-ssb-room/internal/repo"
|
||||
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
|
||||
"github.com/ssb-ngi-pointer/go-ssb-room/roomstate"
|
||||
"github.com/ssb-ngi-pointer/go-ssb-room/web"
|
||||
"github.com/ssb-ngi-pointer/go-ssb-room/web/handlers/admin"
|
||||
|
@ -42,6 +42,7 @@ var HTMLTemplates = []string{
|
|||
|
||||
// Databases is an options stuct for the required databases of the web handlers
|
||||
type Databases struct {
|
||||
Aliases roomdb.AliasService
|
||||
AuthWithSSB roomdb.AuthWithSSBService
|
||||
AuthFallback roomdb.AuthFallbackService
|
||||
AllowList roomdb.AllowListService
|
||||
|
@ -97,7 +98,11 @@ func New(
|
|||
}),
|
||||
render.InjectTemplateFunc("current_page_is", func(r *http.Request) interface{} {
|
||||
return func(routeName string) bool {
|
||||
url, err := router.CompleteApp().Get(routeName).URLPath()
|
||||
route := router.CompleteApp().Get(routeName)
|
||||
if route == nil {
|
||||
return false
|
||||
}
|
||||
url, err := route.URLPath()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
@ -226,6 +231,7 @@ func New(
|
|||
r,
|
||||
roomState,
|
||||
admin.Databases{
|
||||
Aliases: dbs.Aliases,
|
||||
AllowList: dbs.AllowList,
|
||||
Invites: dbs.Invites,
|
||||
Notices: dbs.Notices,
|
||||
|
|
|
@ -17,6 +17,10 @@ AuthSignOut = "Sign out"
|
|||
AdminDashboardWelcome = "Welcome to your dashboard"
|
||||
AdminDashboardTitle = "Room Admin Dashboard"
|
||||
|
||||
AdminAliasesTitle = "Aliases"
|
||||
AdminAliasesWelcome = "Here you can see and revoke the registerd aliases of this room."
|
||||
AdminAliasesRevoke = "Revoke"
|
||||
|
||||
AdminAllowListTitle = "Members"
|
||||
AdminAllowListWelcome = "Here you can see all the members of the room and ways to add new ones (by their SSB ID) or remove exising ones."
|
||||
AdminAllowListAdd = "Add"
|
||||
|
|
|
@ -9,6 +9,10 @@ const (
|
|||
AdminDashboard = "admin:dashboard"
|
||||
AdminMenu = "admin:menu"
|
||||
|
||||
AdminAliasesOverview = "admin:aliases:overview"
|
||||
AdminAliasesRevokeConfirm = "admin:aliases:revoke:confirm"
|
||||
AdminAliasesRevoke = "admin:aliases:revoke"
|
||||
|
||||
AdminAllowListOverview = "admin:allow-list:overview"
|
||||
AdminAllowListAdd = "admin:allow-list:add"
|
||||
AdminAllowListRemoveConfirm = "admin:allow-list:remove:confirm"
|
||||
|
@ -34,6 +38,10 @@ func Admin(m *mux.Router) *mux.Router {
|
|||
m.Path("/dashboard").Methods("GET").Name(AdminDashboard)
|
||||
m.Path("/menu").Methods("GET").Name(AdminMenu)
|
||||
|
||||
m.Path("/aliases").Methods("GET").Name(AdminAliasesOverview)
|
||||
m.Path("/aliases/revoke/confirm").Methods("GET").Name(AdminAliasesRevokeConfirm)
|
||||
m.Path("/aliases/revoke").Methods("POST").Name(AdminAliasesRevoke)
|
||||
|
||||
m.Path("/members").Methods("GET").Name(AdminAllowListOverview)
|
||||
m.Path("/members/add").Methods("POST").Name(AdminAllowListAdd)
|
||||
m.Path("/members/remove/confirm").Methods("GET").Name(AdminAllowListRemoveConfirm)
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
{{ define "title" }}{{i18n "AdminAliasesRevokeConfirmTitle"}}{{ end }}
|
||||
{{ define "content" }}
|
||||
<div class="flex flex-col justify-center items-center h-64">
|
||||
|
||||
<span
|
||||
id="welcome"
|
||||
class="text-center"
|
||||
>{{i18n "AdminAliasesRevokeConfirmWelcome"}}</span>
|
||||
|
||||
<pre
|
||||
id="verify"
|
||||
class="my-4 font-mono truncate max-w-full text-lg text-gray-700"
|
||||
>{{.Entry.Feed.Ref}}</pre>
|
||||
|
||||
<form id="confirm" action="{{urlTo "admin:aliases:revoke"}}" method="POST">
|
||||
{{ .csrfField }}
|
||||
<input type="hidden" name="name" value={{.Entry.Name}}>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<a
|
||||
href="javascript:history.back()"
|
||||
class="px-4 h-8 shadow rounded flex flex-row justify-center items-center bg-white align-middle text-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-300 focus:ring-opacity-50"
|
||||
>{{i18n "GenericGoBack"}}</a>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="shadow rounded px-4 h-8 text-gray-100 bg-pink-600 hover:bg-pink-700 focus:outline-none focus:ring-2 focus:ring-pink-600 focus:ring-opacity-50"
|
||||
>{{i18n "GenericConfirm"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
|
@ -0,0 +1,69 @@
|
|||
{{ define "title" }}{{i18n "AdminAliasesTitle"}}{{ end }}
|
||||
{{ define "content" }}
|
||||
<h1
|
||||
class="text-3xl tracking-tight font-black text-black mt-2 mb-4"
|
||||
>{{i18n "AdminAliasesTitle"}}</h1>
|
||||
|
||||
<p id="welcome" class="my-2">{{i18n "AdminAliasesWelcome"}}</p>
|
||||
|
||||
<p
|
||||
id="aliasCount"
|
||||
class="text-lg font-bold my-2"
|
||||
>{{i18npl "ListCount" .Count}}</p>
|
||||
|
||||
<ul id="theList" class="divide-y pb-4">
|
||||
{{range .Entries}}
|
||||
<li class="flex flex-row items-center h-12">
|
||||
<span
|
||||
class="font-mono truncate flex-auto text-gray-600 tracking-wider"
|
||||
>{{.Feed.Ref}}</span>
|
||||
|
||||
<a
|
||||
href="{{urlTo "admin:aliases:revoke:confirm" "id" .ID}}"
|
||||
class="pl-4 w-20 py-2 text-center text-gray-400 hover:text-red-600 font-bold cursor-pointer"
|
||||
>{{i18n "AdminAliasesRevoke"}}</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
{{$pageNums := .Paginator.PageNums}}
|
||||
{{$view := .View}}
|
||||
{{if gt $pageNums 1}}
|
||||
<div class="flex flex-row justify-center">
|
||||
{{if not .FirstInView}}
|
||||
<a
|
||||
href="{{urlTo "admin:allow-list:overview"}}?page=1"
|
||||
class="rounded px-3 py-2 text-pink-600 border-transparent hover:border-pink-400 border-2"
|
||||
>1</a>
|
||||
<span
|
||||
class="px-3 py-2 text-gray-400 border-2 border-transparent"
|
||||
>..</span>
|
||||
{{end}}
|
||||
|
||||
{{range $view.Pages}}
|
||||
{{if le . $pageNums}}
|
||||
{{if eq . $view.Current}}
|
||||
<span
|
||||
class="px-3 py-2 cursor-default text-gray-500 border-2 border-transparent"
|
||||
>{{.}}</span>
|
||||
{{else}}
|
||||
<a
|
||||
href="{{urlTo "admin:allow-list:overview"}}?page={{.}}"
|
||||
class="rounded px-3 py-2 mx-1 text-pink-600 border-transparent hover:border-pink-400 border-2"
|
||||
>{{.}}</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if not .LastInView}}
|
||||
<span
|
||||
class="px-3 py-2 text-gray-400 border-2 border-transparent"
|
||||
>..</span>
|
||||
<a
|
||||
href="{{urlTo "admin:allow-list:overview"}}?page={{$view.Last}}"
|
||||
class="rounded px-3 py-2 text-pink-600 border-transparent hover:border-pink-400 border-2"
|
||||
>{{$view.Last}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
|
@ -18,6 +18,15 @@
|
|||
</svg>{{i18n "NavAdminDashboard"}}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="{{urlTo "admin:aliases:overview"}}"
|
||||
class="{{if current_page_is "admin:aliases:overview"}}bg-gray-300 {{else}}hover:bg-gray-200 {{end}}pr-1 pl-2 py-3 sm:py-1 rounded-md flex flex-row items-center font-semibold text-sm text-gray-700 hover:text-gray-800 truncate"
|
||||
>
|
||||
<svg class="text-green-600 w-4 h-4 mr-1" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M23,12L20.56,9.22L20.9,5.54L17.29,4.72L15.4,1.54L12,3L8.6,1.54L6.71,4.72L3.1,5.53L3.44,9.21L1,12L3.44,14.78L3.1,18.47L6.71,19.29L8.6,22.47L12,21L15.4,22.46L17.29,19.28L20.9,18.46L20.56,14.78L23,12M10,17L6,13L7.41,11.59L10,14.17L16.59,7.58L18,9L10,17Z" />
|
||||
</svg>{{i18n "AdminAliasesTitle"}}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="{{urlTo "admin:allow-list:overview"}}"
|
||||
class="{{if current_page_is "admin:allow-list:overview"}}bg-gray-300 {{else}}hover:bg-gray-200 {{end}}pr-1 pl-2 py-3 sm:py-1 rounded-md flex flex-row items-center font-semibold text-sm text-gray-700 hover:text-gray-800 truncate"
|
||||
|
|
Loading…
Reference in New Issue