diff --git a/backend/config/config.go b/backend/config/config.go
index c4c3bdc..f847841 100644
--- a/backend/config/config.go
+++ b/backend/config/config.go
@@ -76,15 +76,19 @@ type UsersConfig struct {
}
type LocalUser struct {
- Username string `yaml:"username" json:"username"`
- PasswordHash string `yaml:"password_hash" json:"password_hash"`
- IsAdmin bool `yaml:"is_admin" json:"is_admin"`
+ Username string `yaml:"username" json:"username"`
+ Password string `yaml:"password,omitempty" json:"password,omitempty"` // plaintext or bcrypt; hashed on first run if plaintext
+ IsAdmin bool `yaml:"is_admin" json:"is_admin"`
}
type DefaultAdminUser struct {
- Username string `yaml:"username" json:"username"`
- Password string `yaml:"password,omitempty" json:"password,omitempty"` // plaintext, used only on first bootstrap
- PasswordHash string `yaml:"password_hash,omitempty" json:"password_hash,omitempty"` // bcrypt hash, stored after bootstrap
+ Username string `yaml:"username" json:"username"`
+ Password string `yaml:"password,omitempty" json:"password,omitempty"` // plaintext or bcrypt; hashed on first run if plaintext
+}
+
+// isBcryptHash returns true if s looks like a bcrypt hash ($2a$, $2b$, $2y$).
+func isBcryptHash(s string) bool {
+ return len(s) >= 60 && (strings.HasPrefix(s, "$2a$") || strings.HasPrefix(s, "$2b$") || strings.HasPrefix(s, "$2y$"))
}
// sources tracks where each config key got its value.
@@ -290,35 +294,54 @@ func Load(configPath string) (*Config, error) {
return c, nil
}
-// BootstrapDefaultAdmin hashes the default admin password and stores it in default_admin.
-// Does not add to local_users or clear default_admin. Call after Load.
-func BootstrapDefaultAdmin(c *Config) (bool, error) {
- if c.ConfigPath == "" || c.Users.DefaultAdmin == nil {
- return false, nil
+// BootstrapUsers hashes plaintext passwords for default_admin and local_users, then writes the config.
+// If password looks like plaintext (not bcrypt), it is hashed and replaced in-place. Call after Load.
+func BootstrapUsers(c *Config) error {
+ if c.ConfigPath == "" {
+ return nil
}
- da := c.Users.DefaultAdmin
- if da.Username == "" || da.Password == "" {
- return false, nil
+ modified := false
+
+ // Default admin
+ if c.Users.DefaultAdmin != nil {
+ da := c.Users.DefaultAdmin
+ if da.Username != "" && da.Password != "" && !isBcryptHash(da.Password) {
+ hash, err := bcrypt.GenerateFromPassword([]byte(da.Password), bcrypt.DefaultCost)
+ if err != nil {
+ return fmt.Errorf("hash default admin password: %w", err)
+ }
+ da.Password = string(hash)
+ modified = true
+ log.Printf("[config] bootstrapped default admin %q (password hashed)", da.Username)
+ }
}
- // Already bootstrapped (hash present)
- if da.PasswordHash != "" {
- return false, nil
+
+ // Local users
+ for i := range c.Users.LocalUsers {
+ u := &c.Users.LocalUsers[i]
+ if u.Password == "" || isBcryptHash(u.Password) {
+ continue
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
+ if err != nil {
+ return fmt.Errorf("hash password for local user %q: %w", u.Username, err)
+ }
+ u.Password = string(hash)
+ modified = true
+ log.Printf("[config] bootstrapped local user %q (password hashed)", u.Username)
}
- hash, err := bcrypt.GenerateFromPassword([]byte(da.Password), bcrypt.DefaultCost)
- if err != nil {
- return false, fmt.Errorf("hash default admin password: %w", err)
+
+ if !modified {
+ return nil
}
- da.PasswordHash = string(hash)
- da.Password = ""
data, err := marshalConfigForPath(c)
if err != nil {
- return false, err
+ return err
}
if err := os.WriteFile(c.ConfigPath, data, 0600); err != nil {
- return false, fmt.Errorf("write config after bootstrap: %w", err)
+ return fmt.Errorf("write config after bootstrap: %w", err)
}
- log.Printf("[config] bootstrapped default admin %q (password hashed, stored in default_admin)", da.Username)
- return true, nil
+ return nil
}
func marshalConfigForPath(c *Config) ([]byte, error) {
diff --git a/backend/handlers/auth.go b/backend/handlers/auth.go
index 4f20ae3..d520483 100644
--- a/backend/handlers/auth.go
+++ b/backend/handlers/auth.go
@@ -92,7 +92,7 @@ func NewAuthHandler(cfg *config.Config) *AuthHandler {
localUsers = []config.LocalUser{}
}
oauthProviders := buildOAuthProviders(cfg)
- hasLocalAuth := cfg.Auth.LocalAuthEnabled && (len(localUsers) > 0 || (cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.PasswordHash != ""))
+ hasLocalAuth := cfg.Auth.LocalAuthEnabled && (len(localUsers) > 0 || (cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.Password != ""))
return &AuthHandler{
oauth2Configs: oauth2Configs,
jwtSecret: []byte(cfg.Auth.JWTSecret),
@@ -164,7 +164,7 @@ func (h *AuthHandler) LocalLogin(c *gin.Context) {
// Check local_users first
for i := range h.localUsers {
if strings.EqualFold(h.localUsers[i].Username, username) {
- if err := bcrypt.CompareHashAndPassword([]byte(h.localUsers[i].PasswordHash), []byte(password)); err != nil {
+ if err := bcrypt.CompareHashAndPassword([]byte(h.localUsers[i].Password), []byte(password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
return
}
@@ -173,9 +173,9 @@ func (h *AuthHandler) LocalLogin(c *gin.Context) {
}
}
// Check default_admin if not found in local_users
- if authUsername == "" && h.defaultAdmin != nil && h.defaultAdmin.PasswordHash != "" &&
+ if authUsername == "" && h.defaultAdmin != nil && h.defaultAdmin.Password != "" &&
strings.EqualFold(h.defaultAdmin.Username, username) {
- if err := bcrypt.CompareHashAndPassword([]byte(h.defaultAdmin.PasswordHash), []byte(password)); err != nil {
+ if err := bcrypt.CompareHashAndPassword([]byte(h.defaultAdmin.Password), []byte(password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
return
}
@@ -430,7 +430,7 @@ func (h *AuthHandler) Me(c *gin.Context) {
}
}
}
- if !isAdmin && h.defaultAdmin != nil && h.defaultAdmin.PasswordHash != "" &&
+ if !isAdmin && h.defaultAdmin != nil && h.defaultAdmin.Password != "" &&
strings.EqualFold(h.defaultAdmin.Username, emailStr) {
isAdmin = true
}
diff --git a/backend/handlers/config.go b/backend/handlers/config.go
index f0f60fc..41937e7 100644
--- a/backend/handlers/config.go
+++ b/backend/handlers/config.go
@@ -136,13 +136,13 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
}
}
- // Build local_users for UI (no password_hash)
+ // Build local_users for UI (password not exposed)
localUsersUI := make([]map[string]interface{}, 0, len(cfg.Users.LocalUsers))
for _, u := range cfg.Users.LocalUsers {
localUsersUI = append(localUsersUI, map[string]interface{}{
"username": u.Username,
"is_admin": u.IsAdmin,
- "password_set": u.PasswordHash != "",
+ "password_set": u.Password != "",
})
}
@@ -152,7 +152,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
defaultAdminPasswordSet := false
if cfg.Users.DefaultAdmin != nil {
defaultAdminUsername = cfg.Users.DefaultAdmin.Username
- defaultAdminPasswordSet = cfg.Users.DefaultAdmin.Password != "" || cfg.Users.DefaultAdmin.PasswordHash != ""
+ defaultAdminPasswordSet = cfg.Users.DefaultAdmin.Password != ""
}
values := ConfigValuesResponse{
@@ -295,7 +295,6 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
da := &config.DefaultAdminUser{Username: v}
if prev != nil {
da.Password = prev.Password
- da.PasswordHash = prev.PasswordHash
}
cfg.Users.DefaultAdmin = da
} else {
@@ -310,8 +309,8 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
// Keep existing credentials on hash failure
} else {
cfg.Users.DefaultAdmin = &config.DefaultAdminUser{
- Username: cfg.Users.DefaultAdmin.Username,
- PasswordHash: string(hash),
+ Username: cfg.Users.DefaultAdmin.Username,
+ Password: string(hash),
}
}
}
@@ -369,9 +368,9 @@ func toLocalUsers(v interface{}, existing []config.LocalUser) ([]config.LocalUse
if !ok {
return nil, false
}
- existingByUser := make(map[string]string) // username -> password_hash
+ existingByUser := make(map[string]string) // username -> stored password (hash)
for _, u := range existing {
- existingByUser[u.Username] = u.PasswordHash
+ existingByUser[u.Username] = u.Password
}
out := make([]config.LocalUser, 0, len(arr))
for _, a := range arr {
@@ -406,7 +405,7 @@ func toLocalUsers(v interface{}, existing []config.LocalUser) ([]config.LocalUse
}
}
}
- out = append(out, config.LocalUser{Username: username, PasswordHash: hash, IsAdmin: isAdmin})
+ out = append(out, config.LocalUser{Username: username, Password: hash, IsAdmin: isAdmin})
}
return out, true
}
diff --git a/backend/main.go b/backend/main.go
index f74604d..4723768 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -16,8 +16,8 @@ func main() {
if err != nil {
log.Fatalf("config: %v", err)
}
- if _, err := config.BootstrapDefaultAdmin(cfg); err != nil {
- log.Fatalf("config bootstrap default admin: %v", err)
+ if err := config.BootstrapUsers(cfg); err != nil {
+ log.Fatalf("config bootstrap: %v", err)
}
if err := os.MkdirAll(cfg.Server.RootPath, 0750); err != nil {
@@ -58,7 +58,7 @@ func main() {
localAdminUsernames = append(localAdminUsernames, u.Username)
}
}
- if cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.PasswordHash != "" {
+ if cfg.Users.DefaultAdmin != nil && cfg.Users.DefaultAdmin.Password != "" {
localAdminUsernames = append(localAdminUsernames, cfg.Users.DefaultAdmin.Username)
}
configGroup := api.Group("/config", middleware.Auth(), middleware.RequireAdmin(cfg.Users.AdminEmails, localAdminUsernames))
diff --git a/config.example.yaml b/config.example.yaml
index 1c118b7..15f426b 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -31,4 +31,4 @@ users:
# default_admin: bootstrap on startup (password hashed, stored in default_admin; not added to local_users)
# default_admin:
# username: admin
- # password: changeme # replaced by password_hash after first run
+ # password: changeme # plaintext hashed on first run and replaced in-place
diff --git a/docs/authentication/index.md b/docs/authentication/index.md
index bac0fa7..a2412ba 100644
--- a/docs/authentication/index.md
+++ b/docs/authentication/index.md
@@ -4,7 +4,7 @@ icon: material/shield-account
features:
- title: Local users
icon: fontawesome/regular/user
- description: Local with username and password stored in the config file in bcrypt hash format.
+ description: Local username and password. Use `password` (plaintext or bcrypt; plaintext hashed on first run).
- title: Social Authentication
icon: material/login
description: Use Google or Github as OAuth providers.
@@ -19,6 +19,8 @@ Filetree supports **Google OAuth**, **GitHub OAuth**, and **local username/passw
Local auth uses username and password stored in the config file. No OAuth or external provider is required. Filetree stores the password in a bcrypt hash format. On success, a JWT is issued and the frontend stores it for subsequent requests.
+Both `default_admin` and `local_users` use a single `password` field. Use plaintext (e.g. `changeme`) — on first startup, Filetree hashes it and replaces it in-place. You can also provide a bcrypt hash (e.g. from `htpasswd -nbB user pass`).
+
=== "YAML"
``` yaml title="config.yaml"
auth:
@@ -31,9 +33,9 @@ Local auth uses username and password stored in the config file. No OAuth or ext
username: admin
password: changeme
local_users:
- username: heapoftrash
- password_hash: $2a$10$...
- is_admin: false
+ - username: heapoftrash
+ password: changeme
+ is_admin: false
```
=== "JSON"
@@ -49,11 +51,13 @@ Local auth uses username and password stored in the config file. No OAuth or ext
"username": "admin",
"password": "changeme"
},
- "local_users": {
- "username": "heapoftrash",
- "password_hash": "$2a$10$...",
- "is_admin": false
- }
+ "local_users": [
+ {
+ "username": "heapoftrash",
+ "password": "changeme",
+ "is_admin": false
+ }
+ ]
}
}
```
@@ -162,7 +166,7 @@ An example config of all authentication methods
admin_emails: [admin@example.com]
local_users:
- username: bob
- password_hash: $2a$10$... # set via Admin UI or default_admin
+ password: changeme # plaintext hashed on first run, or use bcrypt hash
is_admin: false
default_admin:
username: admin
@@ -195,7 +199,7 @@ An example config of all authentication methods
"local_users": [
{
"username": "bob",
- "password_hash": "$2a$10$...",
+ "password": "changeme",
"is_admin": false
}
],
@@ -207,6 +211,9 @@ An example config of all authentication methods
}
```
+!!! note "Password bootstrap"
+ `default_admin` and `local_users` use a single `password` field. Plaintext is hashed on first startup and replaced in-place. You can also provide a bcrypt hash directly.
+
!!! note "OAuth admins"
`admin_emails` applies **only to OAuth users** (Google/GitHub). For local users, set `is_admin: true` per user in `local_users`.
diff --git a/docs/authentication/setup.md b/docs/authentication/setup.md
index 93be0a6..840da4b 100644
--- a/docs/authentication/setup.md
+++ b/docs/authentication/setup.md
@@ -22,8 +22,8 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`.
users:
default_admin:
username: admin
- password: changeme # hashed on first run; add more users via Settings
- local_users: [] # or add {username, password_hash, is_admin} here
+ password: changeme # hashed on first run
+ local_users: [] # or add {username, password, is_admin} — password hashed on first run
```
=== "Environment variables"
@@ -99,7 +99,7 @@ Copy `config.example.yaml` to `config.yaml` and set `CONFIG_FILE=./config.yaml`.
users:
local_users:
- username: admin
- password_hash: $2a$10$...
+ password: $2a$10$...
is_admin: true
```
diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md
index a33b0ba..3f425cc 100644
--- a/docs/configuration/config-file.md
+++ b/docs/configuration/config-file.md
@@ -53,8 +53,8 @@ Copy `config.example.yaml` or `config.example.json` to `config.yaml` / `config.j
| Key | Type | Description |
|-----|------|-------------|
| `admin_emails` | []string | OAuth user emails that get admin role |
-| `local_users` | []object | `{username, password_hash, is_admin}` |
-| `default_admin` | object | `{username, password}` for bootstrap when no users exist |
+| `local_users` | []object | `{username, password, is_admin}` — `password` accepts plaintext (hashed on first run) or bcrypt hash |
+| `default_admin` | object | `{username, password}` — `password` accepts plaintext (hashed on first run) or bcrypt hash |
## Example
diff --git a/docs/features/simple-admin.md b/docs/features/simple-admin.md
index 46dacfd..e4394bb 100644
--- a/docs/features/simple-admin.md
+++ b/docs/features/simple-admin.md
@@ -26,4 +26,4 @@ Changes are written back to the config file. No database migrations or extra set
## Default admin bootstrap
-When no users exist, you can define a `default_admin` in config with `username` and `password`. On first successful login, the password is hashed and stored in `default_admin.password_hash`. The user is not added to `local_users`; change the password via the admin UI or config to persist.
+When no users exist, you can define a `default_admin` in config with `username` and `password`. On first startup, plaintext password is hashed and replaced in-place. The user is not added to `local_users`; change the password via the admin UI or config to persist.
diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx
index 0c79d07..21cae70 100644
--- a/frontend/src/components/Settings.tsx
+++ b/frontend/src/components/Settings.tsx
@@ -20,6 +20,7 @@ import {
import {
CloudServerOutlined,
DeleteOutlined,
+ InfoCircleOutlined,
SafetyOutlined,
GoogleOutlined,
GithubOutlined,
@@ -47,6 +48,17 @@ const SECTION_ICONS: Record = {
users: ,
}
+const ExtraWithIcon = ({ children }: { children: React.ReactNode }) => {
+ const { token } = theme.useToken()
+ if (!children) return null
+ return (
+
+
+ {children}
+
+ )
+}
+
const TOP_LEVEL_SECTIONS = [
{ id: 'server', label: 'Server' },
{ id: 'auth_providers', label: 'Auth Providers' },
@@ -341,7 +353,7 @@ export default function Settings() {
style={{ marginBottom: 16 }}
/>
- Changes may require a server restart to take effect.
+ Changes may require a server restart to take effect.