server admin panel is now supports multiple operators profiles

This commit is contained in:
onysd 2026-09-08 01:01:35 +03:00
parent e48160ac3a
commit 280321b902
25 changed files with 2197 additions and 128 deletions

View file

@ -0,0 +1,125 @@
package main
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// AdminConsoleUser is one named panel operator. It deliberately never carries
// the password hash outside authentication: everything that renders or returns
// a user uses this shape, so a hash cannot leak into an API response by
// someone adding a field to a JSON struct.
type AdminConsoleUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
Permissions []string `json:"permissions"`
Enabled bool `json:"enabled"`
TokenEpoch int32 `json:"token_epoch"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
}
// adminConsoleCredential is the authentication-only view: the hash plus the
// few fields a login decision needs. Kept unexported and separate from
// AdminConsoleUser so the hash has exactly one reason to be read.
type adminConsoleCredential struct {
ID int64
Username string
PasswordHash string
Permissions []string
Enabled bool
TokenEpoch int32
}
// errAdminUserNotFound is returned instead of pgx.ErrNoRows so callers can
// treat "no such operator" without importing pgx.
var errAdminUserNotFound = errors.New("admin console user not found")
const adminConsoleUserColumns = `id, username, permissions, enabled, token_epoch, created_at, updated_at, last_login_at`
// AdminConsoleCredentialByUsername loads the authentication view for a login
// attempt. The lookup is case-insensitive to match the unique index, so an
// operator cannot be shadowed by a differently-cased duplicate.
func (s *readStore) AdminConsoleCredentialByUsername(ctx context.Context, username string) (adminConsoleCredential, error) {
var out adminConsoleCredential
err := s.pool.QueryRow(ctx, `
SELECT id, username, password_hash, permissions, enabled, token_epoch
FROM admin_console_users
WHERE lower(username) = lower($1)`, strings.TrimSpace(username)).Scan(
&out.ID, &out.Username, &out.PasswordHash, &out.Permissions, &out.Enabled, &out.TokenEpoch)
if errors.Is(err, pgx.ErrNoRows) {
return adminConsoleCredential{}, errAdminUserNotFound
}
if err != nil {
return adminConsoleCredential{}, fmt.Errorf("load admin console credential: %w", err)
}
return out, nil
}
// AdminConsoleSessionState re-reads the two things a live session depends on.
// requireAuthAPI calls it per request so that disabling an operator, editing
// their rights or changing their password takes effect immediately rather than
// whenever their signed cookie happens to expire.
func (s *readStore) AdminConsoleSessionState(ctx context.Context, id int64) (enabled bool, epoch int32, permissions []string, err error) {
err = s.pool.QueryRow(ctx, `
SELECT enabled, token_epoch, permissions FROM admin_console_users WHERE id = $1`, id).
Scan(&enabled, &epoch, &permissions)
if errors.Is(err, pgx.ErrNoRows) {
return false, 0, nil, errAdminUserNotFound
}
if err != nil {
return false, 0, nil, fmt.Errorf("load admin console session state: %w", err)
}
return enabled, epoch, permissions, nil
}
// ListAdminConsoleUsers returns every operator, newest last so the list reads
// like the order they were added.
func (s *readStore) ListAdminConsoleUsers(ctx context.Context) ([]AdminConsoleUser, error) {
rows, err := s.pool.Query(ctx, `
SELECT `+adminConsoleUserColumns+` FROM admin_console_users ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list admin console users: %w", err)
}
defer rows.Close()
out := []AdminConsoleUser{}
for rows.Next() {
var u AdminConsoleUser
if err := rows.Scan(&u.ID, &u.Username, &u.Permissions, &u.Enabled,
&u.TokenEpoch, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt); err != nil {
return nil, fmt.Errorf("scan admin console user: %w", err)
}
if u.Permissions == nil {
u.Permissions = []string{}
}
out = append(out, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate admin console users: %w", err)
}
return out, nil
}
// CountEnabledAdminConsoleUsersWith reports how many enabled operators hold a
// given permission, counting the '*' wildcard as holding everything. It exists
// for the last-administrator guard: the panel refuses the edit that would
// leave nobody able to manage operators.
func (s *readStore) CountEnabledAdminConsoleUsersWith(ctx context.Context, permission string, excludeID int64) (int, error) {
var n int
if err := s.pool.QueryRow(ctx, `
SELECT count(*)::int FROM admin_console_users
WHERE enabled
AND id <> $2
AND (permissions @> ARRAY[$1]::text[] OR permissions @> ARRAY['*']::text[])`,
permission, excludeID).Scan(&n); err != nil {
return 0, fmt.Errorf("count admin console users with permission: %w", err)
}
return n, nil
}