CLI: Improve usage descriptions of client/user management commands #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-09-18 14:30:19 +02:00
parent 887a39e7d9
commit b40e4c5597
5 changed files with 139 additions and 7 deletions

View File

@@ -1,7 +1,12 @@
package acl
import (
"sort"
"strings"
)
// RoleStrings represents user role names mapped to roles.
type RoleStrings = map[string]Role
type RoleStrings map[string]Role
// UserRoles maps valid user account roles.
var UserRoles = RoleStrings{
@@ -20,6 +25,34 @@ var ClientRoles = RoleStrings{
string(RoleNone): RoleNone,
}
// Strings returns the roles as string slice.
func (m RoleStrings) Strings() []string {
result := make([]string, 0, len(m))
for r := range m {
if r != "" {
result = append(result, r)
}
}
sort.Strings(result)
return result
}
// String returns the comma separated roles as string.
func (m RoleStrings) String() string {
return strings.Join(m.Strings(), ", ")
}
// CliUsageString returns the roles as string for use in CLI usage descriptions.
func (m RoleStrings) CliUsageString() string {
s := m.Strings()
if l := len(s); l > 1 {
s[l-1] = "or " + s[l-1]
}
return strings.Join(s, ", ")
}
// Roles grants permissions to roles.
type Roles map[Role]Grant

View File

@@ -0,0 +1,89 @@
package acl
import (
"sort"
"testing"
"github.com/stretchr/testify/assert"
)
func TestRoleStrings_Strings_SortedAndNoEmpty(t *testing.T) {
m := RoleStrings{
"visitor": RoleVisitor,
"": RoleNone,
"guest": RoleGuest,
"admin": RoleAdmin,
}
got := m.Strings()
// Expect deterministic, sorted output and no empty entries.
assert.Equal(t, []string{"admin", "guest", "visitor"}, got)
assert.True(t, sort.StringsAreSorted(got))
}
func TestRoleStrings_String_Join(t *testing.T) {
m := RoleStrings{
"b": RoleUser,
"a": RoleAdmin,
}
// Sorted keys joined by ", ".
assert.Equal(t, "a, b", m.String())
}
func TestRoleStrings_CliUsageString(t *testing.T) {
t.Run("empty", func(t *testing.T) {
assert.Equal(t, "", (RoleStrings{}).CliUsageString())
})
t.Run("single", func(t *testing.T) {
m := RoleStrings{"admin": RoleAdmin}
assert.Equal(t, "admin", m.CliUsageString())
})
t.Run("two", func(t *testing.T) {
m := RoleStrings{"guest": RoleGuest, "admin": RoleAdmin}
// Note the comma before "or" matches current implementation.
assert.Equal(t, "admin, or guest", m.CliUsageString())
})
t.Run("three", func(t *testing.T) {
m := RoleStrings{"visitor": RoleVisitor, "guest": RoleGuest, "admin": RoleAdmin}
assert.Equal(t, "admin, guest, or visitor", m.CliUsageString())
})
}
func TestRoles_Allow(t *testing.T) {
t.Run("specific role grant", func(t *testing.T) {
roles := Roles{
RoleVisitor: GrantViewShared, // denies delete
}
assert.True(t, roles.Allow(RoleVisitor, ActionView))
assert.True(t, roles.Allow(RoleVisitor, ActionDownload))
assert.False(t, roles.Allow(RoleVisitor, ActionDelete))
})
t.Run("default fallback used", func(t *testing.T) {
roles := Roles{
RoleDefault: GrantViewAll, // allows view, denies delete
}
assert.True(t, roles.Allow(RoleUser, ActionView))
assert.False(t, roles.Allow(RoleUser, ActionDelete))
})
t.Run("specific overrides default (no fallback)", func(t *testing.T) {
roles := Roles{
RoleVisitor: GrantViewShared, // denies delete
RoleDefault: GrantFullAccess, // would allow delete, must NOT be used
}
assert.False(t, roles.Allow(RoleVisitor, ActionDelete))
})
t.Run("no match and no default", func(t *testing.T) {
roles := Roles{
RoleVisitor: GrantViewShared,
}
assert.False(t, roles.Allow(RoleUser, ActionView))
})
}

View File

@@ -1,6 +1,8 @@
package commands
import (
"fmt"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/auth/acl"
@@ -13,8 +15,7 @@ const (
ClientIdUsage = "static client `UID` for test purposes"
ClientSecretUsage = "static client `SECRET` for test purposes"
ClientNameUsage = "`CLIENT` name to help identify the application"
ClientRoleUsage = "client authorization `ROLE`"
ClientAuthScope = "client authorization `SCOPES` e.g. \"metrics\" or \"photos albums\" (\"*\" to allow all)"
ClientAuthScope = "client authorization `SCOPES`, e.g. metrics or \"vision photos albums\" (\"*\" to allow all)"
ClientAuthProvider = "client authentication `PROVIDER`"
ClientAuthMethod = "client authentication `METHOD`"
ClientAuthExpires = "access token `LIFETIME` in seconds, after which a new token must be requested"
@@ -25,6 +26,10 @@ const (
ClientSecretInfo = "\nPLEASE WRITE DOWN THE %s CLIENT SECRET, AS YOU WILL NOT BE ABLE TO SEE IT AGAIN:\n"
)
var (
ClientRoleUsage = fmt.Sprintf("client authorization `ROLE`, e.g. %s", acl.ClientRoles.CliUsageString())
)
// ClientsCommands configures the client application subcommands.
var ClientsCommands = &cli.Command{
Name: "clients",

View File

@@ -1,6 +1,8 @@
package commands
import (
"fmt"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/auth/acl"
@@ -12,15 +14,18 @@ const (
UserNameUsage = "full `NAME` for display in the interface"
UserEmailUsage = "unique `EMAIL` address of the user"
UserPasswordUsage = "`PASSWORD` for local authentication (8-72 characters)"
UserRoleUsage = "user account `ROLE` (admin or guest)"
UserAuthUsage = "authentication `PROVIDER` (default, local, oidc or none)"
UserAuthIDUsage = "authentication `ID` e.g. Subject ID or Distinguished Name (DN)"
UserAuthIDUsage = "authentication `ID`, e.g. Subject ID or Distinguished Name (DN)"
UserAdminUsage = "makes user super admin with full access"
UserNoLoginUsage = "disables login on the web interface"
UserWebDAVUsage = "allows to sync files via WebDAV"
UserDisable2FA = "deactivates two-factor authentication"
)
var (
UserRoleUsage = fmt.Sprintf("user account `ROLE`, e.g. %s", acl.UserRoles.CliUsageString())
)
// UsersCommands configures the user management subcommands.
var UsersCommands = &cli.Command{
Name: "users",

View File

@@ -689,7 +689,7 @@ var Flags = CliFlags{
}}, {
Flag: &cli.StringFlag{
Name: "portal-url",
Usage: "base `URL` of the cluster portal e.g. https://portal.example.com",
Usage: "base `URL` of the cluster portal, e.g. https://portal.example.com",
EnvVars: EnvVars("PORTAL_URL"),
Hidden: true,
}, Tags: []string{Pro}}, {
@@ -837,7 +837,7 @@ var Flags = CliFlags{
Flag: &cli.StringFlag{
Name: "database-server",
Aliases: []string{"db-server"},
Usage: "database `HOST` incl. port e.g. \"mariadb:3306\" (or socket path)",
Usage: "database `HOST` incl. port, e.g. \"mariadb:3306\" (or socket path)",
EnvVars: EnvVars("DATABASE_SERVER"),
}}, {
Flag: &cli.StringFlag{