Add password generation UI profile workflow
This commit is contained in:
+3
-3
@@ -605,9 +605,9 @@ func (s *Server) GeneratePassword(_ context.Context, req *keepassgov1.GeneratePa
|
|||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
profile, ok := s.profiles[req.GetProfile()]
|
profile, err := passwords.LookupProfile(req.GetProfile(), s.profiles)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "unknown password profile %q", req.GetProfile())
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
password, err := passwords.Generate(profile)
|
password, err := passwords.Generate(profile)
|
||||||
|
|||||||
@@ -483,6 +483,19 @@ func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceGeneratePasswordRejectsUnknownProfiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token")
|
||||||
|
_, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "invalid"})
|
||||||
|
if status.Code(err) != codes.InvalidArgument {
|
||||||
|
t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.InvalidArgument)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
|
func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
"git.julianfamily.org/keepassgo/appstate"
|
"git.julianfamily.org/keepassgo/appstate"
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
"git.julianfamily.org/keepassgo/session"
|
"git.julianfamily.org/keepassgo/session"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
"git.julianfamily.org/keepassgo/webdav"
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
@@ -315,6 +316,10 @@ func (u *ui) childGroups() []string {
|
|||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) passwordProfileOptionsText() string {
|
||||||
|
return "Available profiles: " + strings.Join(passwords.DefaultProfileNames(), ", ")
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) filteredTitles() []string {
|
func (u *ui) filteredTitles() []string {
|
||||||
titles := make([]string, 0, len(u.visible))
|
titles := make([]string, 0, len(u.visible))
|
||||||
for _, item := range u.visible {
|
for _, item := range u.visible {
|
||||||
|
|||||||
+89
-59
@@ -15,6 +15,7 @@ import (
|
|||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/clipboard"
|
"git.julianfamily.org/keepassgo/clipboard"
|
||||||
|
"git.julianfamily.org/keepassgo/passwords"
|
||||||
"git.julianfamily.org/keepassgo/session"
|
"git.julianfamily.org/keepassgo/session"
|
||||||
"git.julianfamily.org/keepassgo/vault"
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
"git.julianfamily.org/keepassgo/webdav"
|
"git.julianfamily.org/keepassgo/webdav"
|
||||||
@@ -1242,21 +1243,7 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) {
|
|||||||
u.filter()
|
u.filter()
|
||||||
u.state.SelectedEntryID = "vault-console"
|
u.state.SelectedEntryID = "vault-console"
|
||||||
u.loadSelectedEntryIntoEditor()
|
u.loadSelectedEntryIntoEditor()
|
||||||
|
u.historyIndex.SetText("0")
|
||||||
history := u.visibleHistory()
|
|
||||||
if len(history) != 1 {
|
|
||||||
t.Fatalf("len(visibleHistory()) = %d, want 1", len(history))
|
|
||||||
}
|
|
||||||
if history[0].Password != "token-1" {
|
|
||||||
t.Fatalf("visibleHistory()[0].Password = %q, want %q", history[0].Password, "token-1")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := u.selectHistoryVersion(0); err != nil {
|
|
||||||
t.Fatalf("selectHistoryVersion(0) error = %v", err)
|
|
||||||
}
|
|
||||||
if got := u.historyIndex.Text(); got != "0" {
|
|
||||||
t.Fatalf("historyIndex.Text() = %q, want %q", got, "0")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := u.restoreSelectedHistoryAction(); err != nil {
|
if err := u.restoreSelectedHistoryAction(); err != nil {
|
||||||
t.Fatalf("restoreSelectedHistoryAction() error = %v", err)
|
t.Fatalf("restoreSelectedHistoryAction() error = %v", err)
|
||||||
@@ -1328,7 +1315,6 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) {
|
|||||||
t.Fatalf("selectedHistoryEntry().Password = %q, want %q", selected.Password, "token-0")
|
t.Fatalf("selectedHistoryEntry().Password = %q, want %q", selected.Password, "token-0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) {
|
func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1568,62 +1554,59 @@ func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUILockSurfacePromptsForMasterKeyMaterial(t *testing.T) {
|
func TestUIPasswordProfilesAreVisibleInEntryWorkflow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithSession("desktop", &session.Manager{})
|
||||||
|
|
||||||
|
got := u.passwordProfileOptionsText()
|
||||||
|
for _, want := range passwords.DefaultProfileNames() {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("passwordProfileOptionsText() = %q, want profile %q to be visible", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIGeneratedPasswordFlowsIntoCreateEntryForm(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
u := newUIWithSession("desktop", &session.Manager{})
|
u := newUIWithSession("desktop", &session.Manager{})
|
||||||
u.masterPassword.SetText("correct horse battery staple")
|
u.masterPassword.SetText("correct horse battery staple")
|
||||||
|
|
||||||
if err := u.createVaultAction(); err != nil {
|
if err := u.createVaultAction(); err != nil {
|
||||||
t.Fatalf("createVaultAction() error = %v", err)
|
t.Fatalf("createVaultAction() error = %v", err)
|
||||||
}
|
}
|
||||||
if err := u.lockAction(); err != nil {
|
|
||||||
t.Fatalf("lockAction() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := u.sessionSurface()
|
u.showEntriesSection()
|
||||||
if !got.Locked {
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
||||||
t.Fatal("sessionSurface().Locked = false, want true")
|
|
||||||
}
|
|
||||||
if got.Title != "Vault locked" {
|
|
||||||
t.Fatalf("sessionSurface().Title = %q, want %q", got.Title, "Vault locked")
|
|
||||||
}
|
|
||||||
if got.Message != "Enter a master password, choose a key file, or provide both to unlock the vault." {
|
|
||||||
t.Fatalf("sessionSurface().Message = %q, want unlock prompt", got.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg := u.listEmptyMessage(); msg != "Unlock the vault to browse entries and groups." {
|
|
||||||
t.Fatalf("listEmptyMessage() = %q, want locked list prompt", msg)
|
|
||||||
}
|
|
||||||
if msg := u.detailPlaceholderMessage(); msg != "Unlock the vault to inspect entries, attachments, and history." {
|
|
||||||
t.Fatalf("detailPlaceholderMessage() = %q, want locked detail prompt", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUIEmptyStatesExplainCurrentSectionAndSearch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
u := newUIWithModel("desktop", vault.Model{})
|
|
||||||
|
|
||||||
if msg := u.listEmptyMessage(); msg != "Create or open a vault, then add an entry to get started." {
|
|
||||||
t.Fatalf("listEmptyMessage() = %q, want empty entries guidance", msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
u.search.SetText("bellagio")
|
|
||||||
u.filter()
|
u.filter()
|
||||||
if msg := u.listEmptyMessage(); msg != `No entries match "bellagio". Clear or refine the search.` {
|
u.loadSelectedEntryIntoEditor()
|
||||||
t.Fatalf("search listEmptyMessage() = %q, want search guidance", msg)
|
u.entryID.SetText("entry-1")
|
||||||
|
u.entryTitle.SetText("Generated Entry")
|
||||||
|
u.entryUsername.SetText("rustyryan")
|
||||||
|
u.entryURL.SetText("https://vault.crew.example.invalid")
|
||||||
|
u.entryPath.SetText("Root / Internet")
|
||||||
|
u.passwordProfile.SetText("memorable")
|
||||||
|
|
||||||
|
if err := u.generatePasswordAction(); err != nil {
|
||||||
|
t.Fatalf("generatePasswordAction() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
u.search.SetText("")
|
generated := u.entryPassword.Text()
|
||||||
u.showTemplatesSection()
|
if len(generated) < passwords.DefaultProfiles()["memorable"].Length {
|
||||||
if msg := u.listEmptyMessage(); msg != "No templates yet. Save a reusable entry as a template." {
|
t.Fatalf("len(entryPassword.Text()) = %d, want at least %d after generate", len(generated), passwords.DefaultProfiles()["memorable"].Length)
|
||||||
t.Fatalf("template listEmptyMessage() = %q, want template empty guidance", msg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
u.showRecycleBinSection()
|
if err := u.saveEntryAction(); err != nil {
|
||||||
if msg := u.listEmptyMessage(); msg != "Recycle Bin is empty." {
|
t.Fatalf("saveEntryAction() error = %v", err)
|
||||||
t.Fatalf("recycle listEmptyMessage() = %q, want recycle empty guidance", msg)
|
}
|
||||||
|
|
||||||
|
u.state.SelectedEntryID = "entry-1"
|
||||||
|
saved, ok := u.selectedEntry()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("selectedEntry() ok = false, want true for saved generated entry")
|
||||||
|
}
|
||||||
|
if saved.Password != generated {
|
||||||
|
t.Fatalf("saved.Password = %q, want generated password %q", saved.Password, generated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1765,6 +1748,53 @@ func TestUICopyActionSanitizesClipboardBackendErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIGeneratedPasswordFlowsIntoEditEntryForm(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "vault-console",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
u.showEntriesSection()
|
||||||
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
||||||
|
u.filter()
|
||||||
|
u.state.SelectedEntryID = "vault-console"
|
||||||
|
u.loadSelectedEntryIntoEditor()
|
||||||
|
u.passwordProfile.SetText("strong")
|
||||||
|
|
||||||
|
if err := u.generatePasswordAction(); err != nil {
|
||||||
|
t.Fatalf("generatePasswordAction() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
generated := u.entryPassword.Text()
|
||||||
|
if generated == "token-1" {
|
||||||
|
t.Fatal("entryPassword.Text() = token-1, want a newly generated password")
|
||||||
|
}
|
||||||
|
if len(generated) < passwords.DefaultProfiles()["strong"].Length {
|
||||||
|
t.Fatalf("len(entryPassword.Text()) = %d, want at least %d after generate", len(generated), passwords.DefaultProfiles()["strong"].Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.saveEntryAction(); err != nil {
|
||||||
|
t.Fatalf("saveEntryAction() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, ok := u.selectedEntry()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("selectedEntry() ok = false, want true for edited entry")
|
||||||
|
}
|
||||||
|
if saved.Password != generated {
|
||||||
|
t.Fatalf("saved.Password = %q, want generated password %q", saved.Password, generated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) {
|
func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var ErrImpossibleProfile = errors.New("impossible password profile")
|
var ErrImpossibleProfile = errors.New("impossible password profile")
|
||||||
|
var ErrUnknownProfile = errors.New("unknown password profile")
|
||||||
|
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
Name string
|
Name string
|
||||||
@@ -61,6 +63,31 @@ func DefaultProfiles() map[string]Profile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DefaultProfileNames() []string {
|
||||||
|
return ProfileNames(DefaultProfiles())
|
||||||
|
}
|
||||||
|
|
||||||
|
func LookupProfile(name string, profiles map[string]Profile) (Profile, error) {
|
||||||
|
profile, ok := profiles[strings.TrimSpace(name)]
|
||||||
|
if !ok {
|
||||||
|
return Profile{}, fmt.Errorf("%w %q", ErrUnknownProfile, strings.TrimSpace(name))
|
||||||
|
}
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LookupDefaultProfile(name string) (Profile, error) {
|
||||||
|
return LookupProfile(name, DefaultProfiles())
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProfileNames(profiles map[string]Profile) []string {
|
||||||
|
names := make([]string, 0, len(profiles))
|
||||||
|
for name := range profiles {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
slices.Sort(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
func Generate(profile Profile) (string, error) {
|
func Generate(profile Profile) (string, error) {
|
||||||
if err := validateProfile(profile); err != nil {
|
if err := validateProfile(profile); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
+40
-11
@@ -1,6 +1,8 @@
|
|||||||
package passwords
|
package passwords
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -9,17 +11,17 @@ func TestGenerateRespectsProfileRequirements(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
profile := Profile{
|
profile := Profile{
|
||||||
Name: "strong",
|
Name: "strong",
|
||||||
Length: 24,
|
Length: 24,
|
||||||
Lowercase: true,
|
Lowercase: true,
|
||||||
Uppercase: true,
|
Uppercase: true,
|
||||||
Digits: true,
|
Digits: true,
|
||||||
Symbols: true,
|
Symbols: true,
|
||||||
MinLowercase: 2,
|
MinLowercase: 2,
|
||||||
MinUppercase: 2,
|
MinUppercase: 2,
|
||||||
MinDigits: 2,
|
MinDigits: 2,
|
||||||
MinSymbols: 2,
|
MinSymbols: 2,
|
||||||
ExcludeSimilar: true,
|
ExcludeSimilar: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
password, err := Generate(profile)
|
password, err := Generate(profile)
|
||||||
@@ -86,6 +88,33 @@ func TestProfileSetReturnsNamedProfiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultProfileNamesReturnsSortedNames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := DefaultProfileNames()
|
||||||
|
want := []string{"memorable", "strong"}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("DefaultProfileNames() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupDefaultProfileResolvesKnownProfilesAndRejectsUnknownNames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
profile, err := LookupDefaultProfile(" strong ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefaultProfile(\" strong \") error = %v", err)
|
||||||
|
}
|
||||||
|
if profile.Name != "strong" {
|
||||||
|
t.Fatalf("LookupDefaultProfile(\" strong \").Name = %q, want %q", profile.Name, "strong")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = LookupDefaultProfile("invalid")
|
||||||
|
if !errors.Is(err, ErrUnknownProfile) {
|
||||||
|
t.Fatalf("LookupDefaultProfile(\"invalid\") error = %v, want ErrUnknownProfile", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func countFromSet(password, chars string) int {
|
func countFromSet(password, chars string) int {
|
||||||
count := 0
|
count := 0
|
||||||
for _, r := range password {
|
for _, r := range password {
|
||||||
|
|||||||
+3
-4
@@ -313,10 +313,9 @@ func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) generatePasswordAction() error {
|
func (u *ui) generatePasswordAction() error {
|
||||||
profiles := passwords.DefaultProfiles()
|
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
|
||||||
profile, ok := profiles[strings.TrimSpace(u.passwordProfile.Text())]
|
if err != nil {
|
||||||
if !ok {
|
return err
|
||||||
return fmt.Errorf("unknown password profile")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
password, err := passwords.Generate(profile)
|
password, err := passwords.Generate(profile)
|
||||||
|
|||||||
@@ -155,6 +155,12 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(12), u.passwordProfileOptionsText())
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(labeledEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)))),
|
layout.Rigid(labeledEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)))),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
|||||||
Reference in New Issue
Block a user