feat: add invalid_users directive for Samba shares
Samba shares now support an 'invalid users' list (deny list), written as 'invalid users = u1,u2' in smb.conf. The UI shows a ChipPicker for valid_users and invalid_users, mutually exclusive, sourced from the system user list. feat: add ImportSystemUsers for fresh installations When NASCTL_IMPORT_ON_BOOT=true, nasctl now imports existing system users from /etc/passwd (UID 1000-60000) and /etc/group (supplemental groups), and detects which have Samba accounts via 'pdbedit -L'. Imported users are marked dirty so the admin can review before applying. New POST /api/import/users endpoint for manual re-import. This mirrors the existing import-on-boot flow for smb.conf and /etc/exports.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
BINARY=nasctl
|
BINARY=nasctl
|
||||||
VERSION?=0.5.3
|
VERSION?=0.6.0
|
||||||
GO?=go
|
GO?=go
|
||||||
LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
BUILD_FLAGS=CGO_ENABLED=0
|
BUILD_FLAGS=CGO_ENABLED=0
|
||||||
|
|||||||
+5
-1
@@ -59,13 +59,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if *importOnBoot {
|
if *importOnBoot {
|
||||||
result := importer.ImportOnBoot(nil, database, *smbConfPath, *exportsPath)
|
result := importer.ImportOnBoot(nil, database, *smbConfPath, *exportsPath, *adminUser)
|
||||||
if result.SambaImported > 0 {
|
if result.SambaImported > 0 {
|
||||||
log.Printf("[importer] imported %d existing samba shares — review and apply", result.SambaImported)
|
log.Printf("[importer] imported %d existing samba shares — review and apply", result.SambaImported)
|
||||||
}
|
}
|
||||||
if result.NFSImported > 0 {
|
if result.NFSImported > 0 {
|
||||||
log.Printf("[importer] imported %d existing nfs exports — review and apply", result.NFSImported)
|
log.Printf("[importer] imported %d existing nfs exports — review and apply", result.NFSImported)
|
||||||
}
|
}
|
||||||
|
if result.UsersImported > 0 {
|
||||||
|
log.Printf("[importer] imported %d system users — review and apply", result.UsersImported)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sambaModule := samba.New(samba.Config{
|
sambaModule := samba.New(samba.Config{
|
||||||
@@ -87,6 +90,7 @@ func main() {
|
|||||||
Auth: auth,
|
Auth: auth,
|
||||||
SMBConfPath: *smbConfPath,
|
SMBConfPath: *smbConfPath,
|
||||||
ExportsPath: *exportsPath,
|
ExportsPath: *exportsPath,
|
||||||
|
AdminUsername: *adminUser,
|
||||||
UploadMaxBytes: *uploadMaxBytes,
|
UploadMaxBytes: *uploadMaxBytes,
|
||||||
PreviewMaxBytes: *previewMaxBytes,
|
PreviewMaxBytes: *previewMaxBytes,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE samba_shares ADD COLUMN invalid_users TEXT NOT NULL DEFAULT '[]';
|
||||||
@@ -33,6 +33,7 @@ type SambaShare struct {
|
|||||||
GuestOK bool `json:"guest_ok"`
|
GuestOK bool `json:"guest_ok"`
|
||||||
ValidUsers []string `json:"valid_users"`
|
ValidUsers []string `json:"valid_users"`
|
||||||
ValidGroups []string `json:"valid_groups"`
|
ValidGroups []string `json:"valid_groups"`
|
||||||
|
InvalidUsers []string `json:"invalid_users"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-8
@@ -42,7 +42,7 @@ func scanSambaShare(row interface {
|
|||||||
}) (SambaShare, error) {
|
}) (SambaShare, error) {
|
||||||
var share SambaShare
|
var share SambaShare
|
||||||
var readOnly, guestOK int
|
var readOnly, guestOK int
|
||||||
var validUsers, validGroups, createdAt, updatedAt string
|
var validUsers, validGroups, invalidUsers, createdAt, updatedAt string
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
&share.ID,
|
&share.ID,
|
||||||
&share.Name,
|
&share.Name,
|
||||||
@@ -52,6 +52,7 @@ func scanSambaShare(row interface {
|
|||||||
&guestOK,
|
&guestOK,
|
||||||
&validUsers,
|
&validUsers,
|
||||||
&validGroups,
|
&validGroups,
|
||||||
|
&invalidUsers,
|
||||||
&createdAt,
|
&createdAt,
|
||||||
&updatedAt,
|
&updatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -68,6 +69,10 @@ func scanSambaShare(row interface {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return SambaShare{}, err
|
return SambaShare{}, err
|
||||||
}
|
}
|
||||||
|
share.InvalidUsers, err = decodeJSONStrings(invalidUsers)
|
||||||
|
if err != nil {
|
||||||
|
return SambaShare{}, err
|
||||||
|
}
|
||||||
share.CreatedAt = parseTime(createdAt)
|
share.CreatedAt = parseTime(createdAt)
|
||||||
share.UpdatedAt = parseTime(updatedAt)
|
share.UpdatedAt = parseTime(updatedAt)
|
||||||
return share, nil
|
return share, nil
|
||||||
@@ -170,7 +175,7 @@ func (d *DB) ListApplyLog(limit int) ([]ApplyLogEntry, error) {
|
|||||||
|
|
||||||
func (d *DB) ListSambaShares() ([]SambaShare, error) {
|
func (d *DB) ListSambaShares() ([]SambaShare, error) {
|
||||||
rows, err := d.conn.Query(`
|
rows, err := d.conn.Query(`
|
||||||
SELECT id, name, path, comment, read_only, guest_ok, valid_users, valid_groups, created_at, updated_at
|
SELECT id, name, path, comment, read_only, guest_ok, valid_users, valid_groups, invalid_users, created_at, updated_at
|
||||||
FROM samba_shares ORDER BY name ASC`)
|
FROM samba_shares ORDER BY name ASC`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list samba shares: %w", err)
|
return nil, fmt.Errorf("list samba shares: %w", err)
|
||||||
@@ -190,7 +195,7 @@ func (d *DB) ListSambaShares() ([]SambaShare, error) {
|
|||||||
|
|
||||||
func (d *DB) GetSambaShare(id int64) (SambaShare, error) {
|
func (d *DB) GetSambaShare(id int64) (SambaShare, error) {
|
||||||
row := d.conn.QueryRow(`
|
row := d.conn.QueryRow(`
|
||||||
SELECT id, name, path, comment, read_only, guest_ok, valid_users, valid_groups, created_at, updated_at
|
SELECT id, name, path, comment, read_only, guest_ok, valid_users, valid_groups, invalid_users, created_at, updated_at
|
||||||
FROM samba_shares WHERE id = ?`, id)
|
FROM samba_shares WHERE id = ?`, id)
|
||||||
share, err := scanSambaShare(row)
|
share, err := scanSambaShare(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -211,6 +216,10 @@ func (d *DB) CreateSambaShare(share SambaShare) (SambaShare, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return SambaShare{}, err
|
return SambaShare{}, err
|
||||||
}
|
}
|
||||||
|
invalidUsers, err := encodeJSONStrings(share.InvalidUsers)
|
||||||
|
if err != nil {
|
||||||
|
return SambaShare{}, err
|
||||||
|
}
|
||||||
readOnly := 0
|
readOnly := 0
|
||||||
if share.ReadOnly {
|
if share.ReadOnly {
|
||||||
readOnly = 1
|
readOnly = 1
|
||||||
@@ -220,9 +229,9 @@ func (d *DB) CreateSambaShare(share SambaShare) (SambaShare, error) {
|
|||||||
guestOK = 1
|
guestOK = 1
|
||||||
}
|
}
|
||||||
result, err := d.conn.Exec(`
|
result, err := d.conn.Exec(`
|
||||||
INSERT INTO samba_shares (name, path, comment, read_only, guest_ok, valid_users, valid_groups)
|
INSERT INTO samba_shares (name, path, comment, read_only, guest_ok, valid_users, valid_groups, invalid_users)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups,
|
share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, invalidUsers,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SambaShare{}, fmt.Errorf("create samba share: %w", err)
|
return SambaShare{}, fmt.Errorf("create samba share: %w", err)
|
||||||
@@ -243,6 +252,10 @@ func (d *DB) UpdateSambaShare(id int64, share SambaShare) (SambaShare, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return SambaShare{}, err
|
return SambaShare{}, err
|
||||||
}
|
}
|
||||||
|
invalidUsers, err := encodeJSONStrings(share.InvalidUsers)
|
||||||
|
if err != nil {
|
||||||
|
return SambaShare{}, err
|
||||||
|
}
|
||||||
readOnly := 0
|
readOnly := 0
|
||||||
if share.ReadOnly {
|
if share.ReadOnly {
|
||||||
readOnly = 1
|
readOnly = 1
|
||||||
@@ -253,9 +266,9 @@ func (d *DB) UpdateSambaShare(id int64, share SambaShare) (SambaShare, error) {
|
|||||||
}
|
}
|
||||||
result, err := d.conn.Exec(`
|
result, err := d.conn.Exec(`
|
||||||
UPDATE samba_shares
|
UPDATE samba_shares
|
||||||
SET name = ?, path = ?, comment = ?, read_only = ?, guest_ok = ?, valid_users = ?, valid_groups = ?, updated_at = datetime('now')
|
SET name = ?, path = ?, comment = ?, read_only = ?, guest_ok = ?, valid_users = ?, valid_groups = ?, invalid_users = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, id,
|
share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, invalidUsers, id,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SambaShare{}, fmt.Errorf("update samba share: %w", err)
|
return SambaShare{}, fmt.Errorf("update samba share: %w", err)
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ func (d *DB) ReplaceSambaShares(shares []SambaShare) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
invalidUsers, err := encodeJSONStrings(share.InvalidUsers)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
readOnly := 0
|
readOnly := 0
|
||||||
if share.ReadOnly {
|
if share.ReadOnly {
|
||||||
readOnly = 1
|
readOnly = 1
|
||||||
@@ -33,9 +37,9 @@ func (d *DB) ReplaceSambaShares(shares []SambaShare) error {
|
|||||||
guestOK = 1
|
guestOK = 1
|
||||||
}
|
}
|
||||||
_, err = tx.Exec(`
|
_, err = tx.Exec(`
|
||||||
INSERT INTO samba_shares (name, path, comment, read_only, guest_ok, valid_users, valid_groups)
|
INSERT INTO samba_shares (name, path, comment, read_only, guest_ok, valid_users, valid_groups, invalid_users)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups,
|
share.Name, share.Path, share.Comment, readOnly, guestOK, validUsers, validGroups, invalidUsers,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("insert samba share %s: %w", share.Name, err)
|
return fmt.Errorf("insert samba share %s: %w", share.Name, err)
|
||||||
|
|||||||
@@ -201,6 +201,35 @@ func (d *DB) ClearDeletedUser(username string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DB) ReplaceUsers(users []User) error {
|
||||||
|
tx, err := d.conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM system_users`); err != nil {
|
||||||
|
return fmt.Errorf("clear system_users: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range users {
|
||||||
|
groups, err := encodeJSONStrings(u.Groups)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO system_users (username, groups, smb_enabled, disabled, pending_password)
|
||||||
|
VALUES (?, ?, ?, ?, '')`,
|
||||||
|
u.Username, groups, boolToInt(u.SMBEnabled), boolToInt(u.Disabled),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert user %s: %w", u.Username, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DB) GetAdminByUsername(username string) (Admin, error) {
|
func (d *DB) GetAdminByUsername(username string) (Admin, error) {
|
||||||
row := d.conn.QueryRow(
|
row := d.conn.QueryRow(
|
||||||
`SELECT id, username, password_hash, created_at, updated_at FROM admins WHERE username = ?`,
|
`SELECT id, username, password_hash, created_at, updated_at FROM admins WHERE username = ?`,
|
||||||
|
|||||||
@@ -8,34 +8,42 @@ import (
|
|||||||
"github.com/darroyo/nasctl/internal/db"
|
"github.com/darroyo/nasctl/internal/db"
|
||||||
"github.com/darroyo/nasctl/internal/modules/nfs"
|
"github.com/darroyo/nasctl/internal/modules/nfs"
|
||||||
"github.com/darroyo/nasctl/internal/modules/samba"
|
"github.com/darroyo/nasctl/internal/modules/samba"
|
||||||
|
"github.com/darroyo/nasctl/internal/modules/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ImportResult struct {
|
type ImportResult struct {
|
||||||
SambaImported int
|
SambaImported int
|
||||||
NFSImported int
|
NFSImported int
|
||||||
|
UsersImported int
|
||||||
SambaSkipped bool
|
SambaSkipped bool
|
||||||
NFSSkipped bool
|
NFSSkipped bool
|
||||||
|
UsersSkipped bool
|
||||||
SambaError string
|
SambaError string
|
||||||
NFSError string
|
NFSError string
|
||||||
|
UsersError string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImporterDB interface {
|
type ImporterDB interface {
|
||||||
ListSambaShares() ([]db.SambaShare, error)
|
ListSambaShares() ([]db.SambaShare, error)
|
||||||
ListNFSExports() ([]db.NFSExport, error)
|
ListNFSExports() ([]db.NFSExport, error)
|
||||||
|
ListUsers() ([]db.User, error)
|
||||||
ReplaceSambaShares(shares []db.SambaShare) error
|
ReplaceSambaShares(shares []db.SambaShare) error
|
||||||
ReplaceNFSExports(exports []db.NFSExport) error
|
ReplaceNFSExports(exports []db.NFSExport) error
|
||||||
|
ReplaceUsers(users []db.User) error
|
||||||
GetSetting(key string) (string, bool, error)
|
GetSetting(key string) (string, bool, error)
|
||||||
SetSetting(key, value string) error
|
SetSetting(key, value string) error
|
||||||
MarkDirty(module string) error
|
MarkDirty(module string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func ImportOnBoot(ctx context.Context, database ImporterDB, smbPath, exportsPath string) ImportResult {
|
func ImportOnBoot(ctx context.Context, database ImporterDB, smbPath, exportsPath, adminUsername string) ImportResult {
|
||||||
result := ImportResult{}
|
result := ImportResult{}
|
||||||
|
|
||||||
sambaDone, _, _ := database.GetSetting("import.samba.done")
|
sambaDone, _, _ := database.GetSetting("import.samba.done")
|
||||||
nfsDone, _, _ := database.GetSetting("import.nfs.done")
|
nfsDone, _, _ := database.GetSetting("import.nfs.done")
|
||||||
|
usersDone, _, _ := database.GetSetting("import.users.done")
|
||||||
|
|
||||||
if sambaDone == "true" && nfsDone == "true" {
|
allDone := sambaDone == "true" && nfsDone == "true" && usersDone == "true"
|
||||||
|
if allDone {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,8 +67,19 @@ func ImportOnBoot(ctx context.Context, database ImporterDB, smbPath, exportsPath
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.SambaImported > 0 || result.NFSImported > 0 {
|
if usersDone != "true" {
|
||||||
log.Printf("[importer] imported %d samba shares, %d nfs exports", result.SambaImported, result.NFSImported)
|
ur := importUsers(ctx, database, adminUsername)
|
||||||
|
result.UsersImported = ur.count
|
||||||
|
result.UsersSkipped = ur.skipped
|
||||||
|
result.UsersError = ur.err
|
||||||
|
if ur.err == "" {
|
||||||
|
_ = database.SetSetting("import.users.done", "true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.SambaImported > 0 || result.NFSImported > 0 || result.UsersImported > 0 {
|
||||||
|
log.Printf("[importer] imported %d samba shares, %d nfs exports, %d users",
|
||||||
|
result.SambaImported, result.NFSImported, result.UsersImported)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -69,6 +88,7 @@ func ImportOnBoot(ctx context.Context, database ImporterDB, smbPath, exportsPath
|
|||||||
func ResetImportFlags(ctx context.Context, database ImporterDB) error {
|
func ResetImportFlags(ctx context.Context, database ImporterDB) error {
|
||||||
_ = database.SetSetting("import.samba.done", "")
|
_ = database.SetSetting("import.samba.done", "")
|
||||||
_ = database.SetSetting("import.nfs.done", "")
|
_ = database.SetSetting("import.nfs.done", "")
|
||||||
|
_ = database.SetSetting("import.users.done", "")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,3 +153,31 @@ func importNFS(ctx context.Context, database ImporterDB, path string) importStep
|
|||||||
|
|
||||||
return importStep{count: len(exports)}
|
return importStep{count: len(exports)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func importUsers(ctx context.Context, database ImporterDB, adminUsername string) importStep {
|
||||||
|
imported, err := ImportSystemUsers(ctx, adminUsername)
|
||||||
|
if err != nil {
|
||||||
|
return importStep{err: err.Error()}
|
||||||
|
}
|
||||||
|
if imported == nil {
|
||||||
|
return importStep{skipped: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := database.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
return importStep{err: err.Error()}
|
||||||
|
}
|
||||||
|
if len(existing) > 0 {
|
||||||
|
return importStep{skipped: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.ReplaceUsers(imported); err != nil {
|
||||||
|
return importStep{err: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.MarkDirty(users.ModuleName); err != nil {
|
||||||
|
return importStep{count: len(imported), err: fmt.Sprintf("imported but could not mark dirty: %v", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return importStep{count: len(imported)}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/darroyo/nasctl/internal/db"
|
||||||
|
"github.com/darroyo/nasctl/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserMinUID = 1000
|
||||||
|
UserMaxUID = 60000
|
||||||
|
)
|
||||||
|
|
||||||
|
var excludedUsernames = map[string]bool{
|
||||||
|
"nobody": true,
|
||||||
|
"nogroup": true,
|
||||||
|
"sshd": true,
|
||||||
|
"systemd": true,
|
||||||
|
"messagebus": true,
|
||||||
|
"polkitd": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func ImportSystemUsers(ctx context.Context, adminUsername string) ([]db.User, error) {
|
||||||
|
passwdMap, err := readPasswd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
groupMap, err := readGroups()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
smbUsers, err := listSMBUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
smbUsers = map[string]bool{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var users []db.User
|
||||||
|
for username, uid := range passwdMap {
|
||||||
|
if uid < UserMinUID || uid > UserMaxUID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if excludedUsernames[username] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if username == adminUsername {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
users = append(users, db.User{
|
||||||
|
Username: username,
|
||||||
|
Groups: groupMap[username],
|
||||||
|
SMBEnabled: smbUsers[username],
|
||||||
|
Disabled: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(users, func(i, j int) bool {
|
||||||
|
return users[i].Username < users[j].Username
|
||||||
|
})
|
||||||
|
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPasswd() (map[string]int, error) {
|
||||||
|
f, err := os.Open("/etc/passwd")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
result := make(map[string]int)
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(line, ":")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
username := parts[0]
|
||||||
|
uid, err := strconv.Atoi(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[username] = uid
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readGroups() (map[string][]string, error) {
|
||||||
|
f, err := os.Open("/etc/group")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
result := make(map[string][]string)
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(line, ":")
|
||||||
|
if len(parts) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
membersStr := strings.TrimSpace(parts[3])
|
||||||
|
if membersStr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, member := range strings.Split(membersStr, ",") {
|
||||||
|
member = strings.TrimSpace(member)
|
||||||
|
if member != "" {
|
||||||
|
result[member] = append(result[member], parts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSMBUsers(ctx context.Context) (map[string]bool, error) {
|
||||||
|
stdout, _, err := system.Run(ctx, "pdbedit", "-L")
|
||||||
|
if err != nil {
|
||||||
|
return map[string]bool{}, nil
|
||||||
|
}
|
||||||
|
result := make(map[string]bool)
|
||||||
|
for _, line := range strings.Split(stdout, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[parts[0]] = true
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ type templateShare struct {
|
|||||||
GuestOK bool
|
GuestOK bool
|
||||||
ValidUsers []string
|
ValidUsers []string
|
||||||
ValidGroups []string
|
ValidGroups []string
|
||||||
|
InvalidUsers []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type templateData struct {
|
type templateData struct {
|
||||||
@@ -116,6 +117,7 @@ func (m *Module) renderConfig(shares []db.SambaShare) ([]byte, error) {
|
|||||||
GuestOK: share.GuestOK,
|
GuestOK: share.GuestOK,
|
||||||
ValidUsers: share.ValidUsers,
|
ValidUsers: share.ValidUsers,
|
||||||
ValidGroups: share.ValidGroups,
|
ValidGroups: share.ValidGroups,
|
||||||
|
InvalidUsers: share.InvalidUsers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,8 @@
|
|||||||
{{- if .ValidGroups}}
|
{{- if .ValidGroups}}
|
||||||
valid groups = {{join .ValidGroups ","}}
|
valid groups = {{join .ValidGroups ","}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- if .InvalidUsers}}
|
||||||
|
invalid users = {{join .InvalidUsers ","}}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ type Server struct {
|
|||||||
Auth *AuthService
|
Auth *AuthService
|
||||||
SMBConfPath string
|
SMBConfPath string
|
||||||
ExportsPath string
|
ExportsPath string
|
||||||
|
AdminUsername string
|
||||||
UploadMaxBytes int64
|
UploadMaxBytes int64
|
||||||
PreviewMaxBytes int64
|
PreviewMaxBytes int64
|
||||||
}
|
}
|
||||||
@@ -205,6 +206,7 @@ type Options struct {
|
|||||||
Auth *AuthService
|
Auth *AuthService
|
||||||
SMBConfPath string
|
SMBConfPath string
|
||||||
ExportsPath string
|
ExportsPath string
|
||||||
|
AdminUsername string
|
||||||
UploadMaxBytes int64
|
UploadMaxBytes int64
|
||||||
PreviewMaxBytes int64
|
PreviewMaxBytes int64
|
||||||
}
|
}
|
||||||
@@ -223,6 +225,7 @@ func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server {
|
|||||||
Auth: opts.Auth,
|
Auth: opts.Auth,
|
||||||
SMBConfPath: opts.SMBConfPath,
|
SMBConfPath: opts.SMBConfPath,
|
||||||
ExportsPath: opts.ExportsPath,
|
ExportsPath: opts.ExportsPath,
|
||||||
|
AdminUsername: opts.AdminUsername,
|
||||||
UploadMaxBytes: opts.UploadMaxBytes,
|
UploadMaxBytes: opts.UploadMaxBytes,
|
||||||
PreviewMaxBytes: opts.PreviewMaxBytes,
|
PreviewMaxBytes: opts.PreviewMaxBytes,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import (
|
|||||||
"github.com/darroyo/nasctl/internal/importer"
|
"github.com/darroyo/nasctl/internal/importer"
|
||||||
"github.com/darroyo/nasctl/internal/modules/nfs"
|
"github.com/darroyo/nasctl/internal/modules/nfs"
|
||||||
"github.com/darroyo/nasctl/internal/modules/samba"
|
"github.com/darroyo/nasctl/internal/modules/samba"
|
||||||
|
"github.com/darroyo/nasctl/internal/modules/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
type importStatus struct {
|
type importStatus struct {
|
||||||
Samba moduleImportStatus `json:"samba"`
|
Samba moduleImportStatus `json:"samba"`
|
||||||
NFS moduleImportStatus `json:"nfs"`
|
NFS moduleImportStatus `json:"nfs"`
|
||||||
|
Users moduleImportStatus `json:"users"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type moduleImportStatus struct {
|
type moduleImportStatus struct {
|
||||||
@@ -24,9 +26,11 @@ type moduleImportStatus struct {
|
|||||||
func (s *Server) handleImportStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleImportStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
sambaDone, _, _ := s.DB.GetSetting("import.samba.done")
|
sambaDone, _, _ := s.DB.GetSetting("import.samba.done")
|
||||||
nfsDone, _, _ := s.DB.GetSetting("import.nfs.done")
|
nfsDone, _, _ := s.DB.GetSetting("import.nfs.done")
|
||||||
|
usersDone, _, _ := s.DB.GetSetting("import.users.done")
|
||||||
|
|
||||||
shares, _ := s.DB.ListSambaShares()
|
shares, _ := s.DB.ListSambaShares()
|
||||||
exports, _ := s.DB.ListNFSExports()
|
exports, _ := s.DB.ListNFSExports()
|
||||||
|
userList, _ := s.DB.ListUsers()
|
||||||
|
|
||||||
status := importStatus{
|
status := importStatus{
|
||||||
Samba: moduleImportStatus{
|
Samba: moduleImportStatus{
|
||||||
@@ -37,6 +41,10 @@ func (s *Server) handleImportStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
Done: nfsDone == "true",
|
Done: nfsDone == "true",
|
||||||
Count: len(exports),
|
Count: len(exports),
|
||||||
},
|
},
|
||||||
|
Users: moduleImportStatus{
|
||||||
|
Done: usersDone == "true",
|
||||||
|
Count: len(userList),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if ts, ok, _ := s.DB.GetSetting("import.samba.at"); ok {
|
if ts, ok, _ := s.DB.GetSetting("import.samba.at"); ok {
|
||||||
@@ -45,6 +53,9 @@ func (s *Server) handleImportStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
if ts, ok, _ := s.DB.GetSetting("import.nfs.at"); ok {
|
if ts, ok, _ := s.DB.GetSetting("import.nfs.at"); ok {
|
||||||
status.NFS.LastImportAt = ts
|
status.NFS.LastImportAt = ts
|
||||||
}
|
}
|
||||||
|
if ts, ok, _ := s.DB.GetSetting("import.users.at"); ok {
|
||||||
|
status.Users.LastImportAt = ts
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, status)
|
writeJSON(w, http.StatusOK, status)
|
||||||
}
|
}
|
||||||
@@ -115,9 +126,43 @@ func (s *Server) handleImportNFS(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"imported": len(exports)})
|
writeJSON(w, http.StatusOK, map[string]any{"imported": len(exports)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleImportUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := importer.ResetImportFlags(r.Context(), s.DB); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imported, err := importer.ImportSystemUsers(r.Context(), s.AdminUsername)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(imported) == 0 {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"imported": 0, "message": "no system users found to import"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DB.ReplaceUsers(imported); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DB.MarkDirty(users.ModuleName); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.DB.SetSetting("import.users.done", "true")
|
||||||
|
_ = s.DB.SetSetting("import.users.at", time.Now().Format(time.RFC3339))
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"imported": len(imported)})
|
||||||
|
}
|
||||||
|
|
||||||
type dbExporter interface {
|
type dbExporter interface {
|
||||||
ReplaceSambaShares(shares []db.SambaShare) error
|
ReplaceSambaShares(shares []db.SambaShare) error
|
||||||
ReplaceNFSExports(exports []db.NFSExport) error
|
ReplaceNFSExports(exports []db.NFSExport) error
|
||||||
|
ReplaceUsers(users []db.User) error
|
||||||
MarkDirty(module string) error
|
MarkDirty(module string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type sambaShareRequest struct {
|
|||||||
GuestOK bool `json:"guest_ok"`
|
GuestOK bool `json:"guest_ok"`
|
||||||
ValidUsers []string `json:"valid_users"`
|
ValidUsers []string `json:"valid_users"`
|
||||||
ValidGroups []string `json:"valid_groups"`
|
ValidGroups []string `json:"valid_groups"`
|
||||||
|
InvalidUsers []string `json:"invalid_users"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (req sambaShareRequest) validate(allowedRoots []string) error {
|
func (req sambaShareRequest) validate(allowedRoots []string) error {
|
||||||
@@ -45,6 +46,11 @@ func (req sambaShareRequest) validate(allowedRoots []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, user := range req.InvalidUsers {
|
||||||
|
if err := system.ValidateUsername(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +63,7 @@ func (req sambaShareRequest) toModel() db.SambaShare {
|
|||||||
GuestOK: req.GuestOK,
|
GuestOK: req.GuestOK,
|
||||||
ValidUsers: req.ValidUsers,
|
ValidUsers: req.ValidUsers,
|
||||||
ValidGroups: req.ValidGroups,
|
ValidGroups: req.ValidGroups,
|
||||||
|
InvalidUsers: req.InvalidUsers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func NewRouter(s *Server) chi.Router {
|
|||||||
protected.Get("/import/status", s.handleImportStatus)
|
protected.Get("/import/status", s.handleImportStatus)
|
||||||
protected.Post("/import/samba", s.handleImportSamba)
|
protected.Post("/import/samba", s.handleImportSamba)
|
||||||
protected.Post("/import/nfs", s.handleImportNFS)
|
protected.Post("/import/nfs", s.handleImportNFS)
|
||||||
|
protected.Post("/import/users", s.handleImportUsers)
|
||||||
|
|
||||||
protected.Route("/samba/shares", func(shares chi.Router) {
|
protected.Route("/samba/shares", func(shares chi.Router) {
|
||||||
shares.Get("/", s.handleListSambaShares)
|
shares.Get("/", s.handleListSambaShares)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface SambaShare {
|
|||||||
guest_ok: boolean;
|
guest_ok: boolean;
|
||||||
valid_users: string[];
|
valid_users: string[];
|
||||||
valid_groups: string[];
|
valid_groups: string[];
|
||||||
|
invalid_users: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NFSClient {
|
export interface NFSClient {
|
||||||
@@ -213,6 +214,9 @@ export const api = {
|
|||||||
createWatchedMount: (path: string) => request<WatchedMount>("POST", "/system/watched-mounts", { path }),
|
createWatchedMount: (path: string) => request<WatchedMount>("POST", "/system/watched-mounts", { path }),
|
||||||
deleteWatchedMount: (id: number) => request<void>("DELETE", `/system/watched-mounts/${id}`),
|
deleteWatchedMount: (id: number) => request<void>("DELETE", `/system/watched-mounts/${id}`),
|
||||||
|
|
||||||
|
// import
|
||||||
|
importUsers: () => request<{ imported: number; message?: string }>("POST", "/import/users"),
|
||||||
|
|
||||||
// files
|
// files
|
||||||
fileCapabilities: () => request<FileCapabilities>("GET", "/files/capabilities"),
|
fileCapabilities: () => request<FileCapabilities>("GET", "/files/capabilities"),
|
||||||
fileRoots: () => request<{ roots: string[]; unrestricted: boolean }>("GET", "/files/roots"),
|
fileRoots: () => request<{ roots: string[]; unrestricted: boolean }>("GET", "/files/roots"),
|
||||||
|
|||||||
+113
-19
@@ -1,5 +1,5 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { api, SambaShare } from "../api";
|
import { api, SambaShare, User } from "../api";
|
||||||
import { useDirty } from "../DirtyContext";
|
import { useDirty } from "../DirtyContext";
|
||||||
import Modal from "../components/Modal";
|
import Modal from "../components/Modal";
|
||||||
import PathField from "../components/PathField";
|
import PathField from "../components/PathField";
|
||||||
@@ -12,12 +12,73 @@ const empty: Partial<SambaShare> = {
|
|||||||
guest_ok: false,
|
guest_ok: false,
|
||||||
valid_users: [],
|
valid_users: [],
|
||||||
valid_groups: [],
|
valid_groups: [],
|
||||||
|
invalid_users: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ChipPickerProps = {
|
||||||
|
label: string;
|
||||||
|
options: User[];
|
||||||
|
selected: string[];
|
||||||
|
onChange: (selected: string[]) => void;
|
||||||
|
allowRemove?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ChipPicker({ label, options, selected, onChange }: ChipPickerProps) {
|
||||||
|
const available = options.filter((u) => !selected.includes(u.username));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="label">{label}</label>
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{selected.map((username) => {
|
||||||
|
const user = options.find((u) => u.username === username);
|
||||||
|
const isSMB = user?.smb_enabled;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={username}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2.5 py-0.5 text-xs text-emerald-300"
|
||||||
|
>
|
||||||
|
{username}
|
||||||
|
{isSMB && <span title="SMB habilitado">*</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-0.5 rounded-full hover:text-white"
|
||||||
|
onClick={() => onChange(selected.filter((u) => u !== username))}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{available.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{available.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.username}
|
||||||
|
type="button"
|
||||||
|
className="rounded-full bg-slate-700 px-2.5 py-0.5 text-xs text-slate-300 hover:bg-slate-600 hover:text-white"
|
||||||
|
onClick={() => onChange([...selected, user.username])}
|
||||||
|
>
|
||||||
|
+ {user.username}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{options.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-500">No hay usuarios del sistema. Créalos primero.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Samba() {
|
export default function Samba() {
|
||||||
const [shares, setShares] = useState<SambaShare[]>([]);
|
const [shares, setShares] = useState<SambaShare[]>([]);
|
||||||
const [editing, setEditing] = useState<Partial<SambaShare> | null>(null);
|
const [editing, setEditing] = useState<Partial<SambaShare> | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [systemUsers, setSystemUsers] = useState<User[]>([]);
|
||||||
const { refresh } = useDirty();
|
const { refresh } = useDirty();
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -25,10 +86,24 @@ export default function Samba() {
|
|||||||
setShares(res.shares ?? []);
|
setShares(res.shares ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSystemUsers() {
|
||||||
|
try {
|
||||||
|
const res = await api.listUsers();
|
||||||
|
setSystemUsers(res.users ?? []);
|
||||||
|
} catch {
|
||||||
|
setSystemUsers([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function openEditor(share: Partial<SambaShare> | null) {
|
||||||
|
setEditing(share ? { ...share } : { ...empty });
|
||||||
|
loadSystemUsers();
|
||||||
|
}
|
||||||
|
|
||||||
async function save(e: FormEvent) {
|
async function save(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
@@ -58,7 +133,7 @@ export default function Samba() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-white">Shares SMB / Samba</h1>
|
<h1 className="text-2xl font-bold text-white">Shares SMB / Samba</h1>
|
||||||
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
|
<button className="btn-primary" onClick={() => openEditor(null)}>
|
||||||
Nuevo share
|
Nuevo share
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,9 +157,25 @@ export default function Samba() {
|
|||||||
<td className="px-4 py-3 text-slate-300">{s.path}</td>
|
<td className="px-4 py-3 text-slate-300">{s.path}</td>
|
||||||
<td className="px-4 py-3">{s.read_only ? "Solo lectura" : "Lectura/Escritura"}</td>
|
<td className="px-4 py-3">{s.read_only ? "Solo lectura" : "Lectura/Escritura"}</td>
|
||||||
<td className="px-4 py-3">{s.guest_ok ? "Sí" : "No"}</td>
|
<td className="px-4 py-3">{s.guest_ok ? "Sí" : "No"}</td>
|
||||||
<td className="px-4 py-3 text-slate-400">{s.valid_users.join(", ") || "—"}</td>
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{s.valid_users.map((u) => (
|
||||||
|
<span key={u} className="rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-300">
|
||||||
|
{u}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{s.invalid_users.map((u) => (
|
||||||
|
<span key={u} className="rounded-full bg-red-500/20 px-2 py-0.5 text-xs text-red-300">
|
||||||
|
!{u}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{s.valid_users.length === 0 && s.invalid_users.length === 0 && (
|
||||||
|
<span className="text-slate-500">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button className="btn-ghost mr-2" onClick={() => setEditing({ ...s })}>
|
<button className="btn-ghost mr-2" onClick={() => openEditor(s)}>
|
||||||
Editar
|
Editar
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-danger" onClick={() => remove(s.id)}>
|
<button className="btn-danger" onClick={() => remove(s.id)}>
|
||||||
@@ -122,7 +213,7 @@ export default function Samba() {
|
|||||||
<label className="label">Path (absoluto)</label>
|
<label className="label">Path (absoluto)</label>
|
||||||
<PathField
|
<PathField
|
||||||
value={editing.path ?? ""}
|
value={editing.path ?? ""}
|
||||||
onChange={p => setEditing({ ...editing, path: p })}
|
onChange={(p) => setEditing({ ...editing, path: p })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -133,22 +224,25 @@ export default function Samba() {
|
|||||||
onChange={(e) => setEditing({ ...editing, comment: e.target.value })}
|
onChange={(e) => setEditing({ ...editing, comment: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="label">Usuarios válidos (separados por coma)</label>
|
<ChipPicker
|
||||||
<input
|
label="Usuarios válidos (acceso permitido)"
|
||||||
className="input"
|
options={systemUsers}
|
||||||
value={(editing.valid_users ?? []).join(", ")}
|
selected={editing.valid_users ?? []}
|
||||||
onChange={(e) =>
|
onChange={(selected) =>
|
||||||
setEditing({
|
setEditing({ ...editing, valid_users: selected })
|
||||||
...editing,
|
|
||||||
valid_users: e.target.value
|
|
||||||
.split(",")
|
|
||||||
.map((v) => v.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
<ChipPicker
|
||||||
|
label="Usuarios denegados (acceso explícitamente bloqueado)"
|
||||||
|
options={systemUsers}
|
||||||
|
selected={editing.invalid_users ?? []}
|
||||||
|
onChange={(selected) =>
|
||||||
|
setEditing({ ...editing, invalid_users: selected })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -57,10 +57,24 @@ export default function Users() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-white">Usuarios del sistema</h1>
|
<h1 className="text-2xl font-bold text-white">Usuarios del sistema</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn-ghost" onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.importUsers();
|
||||||
|
await load();
|
||||||
|
await refresh();
|
||||||
|
alert(`Importados ${res.imported} usuarios del sistema. Revisa y aplica los cambios.`);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : "Error al importar");
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
Re-importar del sistema
|
||||||
|
</button>
|
||||||
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
|
<button className="btn-primary" onClick={() => setEditing({ ...empty })}>
|
||||||
Nuevo usuario
|
Nuevo usuario
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card overflow-x-auto p-0">
|
<div className="card overflow-x-auto p-0">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
|
|||||||
Reference in New Issue
Block a user