Merge pull request #143 from Dvorinka/fix/dav-mounts-and-readonly

fix: upstream-issue sweep — WebDAV, uploads, recycle, SQLite, frontend
pull/3582/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit 194dcbad2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

2
.gitignore vendored

@ -43,3 +43,5 @@ cloudreve
# Monorepo staging dir for asset packaging (transient)
/assets/
pkg/util/test/
.desloppify/
scorecard.png

@ -102,6 +102,25 @@ dockers:
- .build/entrypoint.sh
image_templates:
- "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-arm64"
- dockerfile: Dockerfile.slim
use: buildx
build_flag_templates:
- "--platform=linux/amd64"
- "--provenance=false"
goos: linux
goarch: amd64
goamd64: v1
image_templates:
- "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim-amd64"
- dockerfile: Dockerfile.slim
use: buildx
build_flag_templates:
- "--platform=linux/arm64"
- "--provenance=false"
goos: linux
goarch: arm64
image_templates:
- "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim-arm64"
docker_manifests:
- name_template: "ghcr.io/dvorinka/cloudreve:latest"
@ -116,3 +135,11 @@ docker_manifests:
image_templates:
- "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-amd64"
- "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-arm64"
- name_template: "ghcr.io/dvorinka/cloudreve:slim"
image_templates:
- "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim-amd64"
- "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim-arm64"
- name_template: "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim"
image_templates:
- "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim-amd64"
- "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim-arm64"

@ -0,0 +1,35 @@
# Slim variant: no LibreOffice, fonts, aria2, or media/thumbnail tooling.
# Build stage
FROM ghcr.io/goreleaser/goreleaser:v2.10.2 AS builder
WORKDIR /src
# Install goreleaser
RUN apk add --no-cache bash curl git npm nodejs tar zip
RUN npm install -g yarn
# Perform the build
COPY . .
RUN goreleaser build --single-target --snapshot
# Runtime stage
FROM alpine:latest
WORKDIR /cloudreve
RUN apk update \
&& apk add --no-cache tzdata \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone
COPY --from=builder /src/dist/*/cloudreve ./cloudreve
RUN chmod +x ./cloudreve
EXPOSE 5212
VOLUME ["/cloudreve/data"]
ENTRYPOINT ["./cloudreve"]

@ -188,7 +188,7 @@ func (s *server) Close() {
}
ctx := context.Background()
if conf.SystemConfig.GracePeriod != 0 {
if s.config.System().GracePeriod != 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(s.config.System().GracePeriod)*time.Second)
defer cancel()
@ -231,8 +231,8 @@ func (s *server) runUnix(server *http.Server) error {
defer listener.Close()
defer os.Remove(s.config.Unix().Listen)
if conf.UnixConfig.Perm > 0 {
err = os.Chmod(conf.UnixConfig.Listen, os.FileMode(s.config.Unix().Perm))
if s.config.Unix().Perm > 0 {
err = os.Chmod(s.config.Unix().Listen, os.FileMode(s.config.Unix().Perm))
if err != nil {
s.logger.Warning(
"Failed to set permission to %q for socket file %q: %s",

@ -3,7 +3,7 @@ package constants
// These values will be injected at build time, DO NOT EDIT.
// BackendVersion 当前后端版本号
var BackendVersion = "4.14.0"
var BackendVersion = "4.19.1"
// IsPro 是否为Pro版本
var IsPro = "false"

@ -72,9 +72,9 @@ type Dep interface {
SettingClient() inventory.SettingClient
// SettingProvider Get a singleton setting.Provider instance for access setting store in strong type.
SettingProvider() setting.Provider
// UserClient Creates a new inventory.UserClient instance for access DB user store.
// UserClient Get a singleton inventory.UserClient instance for access DB user store.
UserClient() inventory.UserClient
// GroupClient Creates a new inventory.GroupClient instance for access DB group store.
// GroupClient Get a singleton inventory.GroupClient instance for access DB group store.
GroupClient() inventory.GroupClient
// EmailClient Get a singleton email.Driver instance for sending emails.
EmailClient(ctx context.Context) email.Driver
@ -82,15 +82,15 @@ type Dep interface {
GeneralAuth() auth.Auth
// Shutdown the dependencies gracefully.
Shutdown(ctx context.Context) error
// FileClient Creates a new inventory.FileClient instance for access DB file store.
// FileClient Get a singleton inventory.FileClient instance for access DB file store.
FileClient() inventory.FileClient
// NodeClient Creates a new inventory.NodeClient instance for access DB node store.
// NodeClient Get a singleton inventory.NodeClient instance for access DB node store.
NodeClient() inventory.NodeClient
// DavAccountClient Creates a new inventory.DavAccountClient instance for access DB dav account store.
// DavAccountClient Get a singleton inventory.DavAccountClient instance for access DB dav account store.
DavAccountClient() inventory.DavAccountClient
// DirectLinkClient Creates a new inventory.DirectLinkClient instance for access DB direct link store.
// DirectLinkClient Get a singleton inventory.DirectLinkClient instance for access DB direct link store.
DirectLinkClient() inventory.DirectLinkClient
// OAuthClientClient Creates a new inventory.OAuthClientClient instance for access DB OAuth client store.
// OAuthClientClient Get a singleton inventory.OAuthClientClient instance for access DB OAuth client store.
OAuthClientClient() inventory.OAuthClientClient
// HashIDEncoder Get a singleton hashid.Encoder instance for encoding/decoding hashids.
HashIDEncoder() hashid.Encoder
@ -98,13 +98,13 @@ type Dep interface {
TokenAuth() auth.TokenAuth
// LockSystem Get a singleton lock.LockSystem instance for file lock management.
LockSystem() lock.LockSystem
// ShareClient Creates a new inventory.ShareClient instance for access DB share store.
// StoragePolicyClient Get a singleton inventory.StoragePolicyClient instance for access DB storage policy store.
StoragePolicyClient() inventory.StoragePolicyClient
// RequestClient Creates a new request.Client instance for HTTP requests.
RequestClient(opts ...request.Option) request.Client
// ShareClient Creates a new inventory.ShareClient instance for access DB share store.
// ShareClient Get a singleton inventory.ShareClient instance for access DB share store.
ShareClient() inventory.ShareClient
// TaskClient Creates a new inventory.TaskClient instance for access DB task store.
// TaskClient Get a singleton inventory.TaskClient instance for access DB task store.
TaskClient() inventory.TaskClient
// ForkWithLogger create a shallow copy of dependency with a new correlated logger, used as per-request dep.
ForkWithLogger(ctx context.Context, l logging.Logger) context.Context
@ -450,7 +450,8 @@ func (d *dependency) FsEventClient() inventory.FsEventClient {
if d.fsEventClient != nil {
return d.fsEventClient
}
return inventory.NewFsEventClient(d.DBClient(), d.ConfigProvider().Database().Type)
d.fsEventClient = inventory.NewFsEventClient(d.DBClient(), d.ConfigProvider().Database().Type)
return d.fsEventClient
}
func (d *dependency) SettingClient() inventory.SettingClient {
@ -499,7 +500,8 @@ func (d *dependency) UserClient() inventory.UserClient {
return d.userClient
}
return inventory.NewUserClient(d.DBClient())
d.userClient = inventory.NewUserClient(d.DBClient())
return d.userClient
}
func (d *dependency) GroupClient() inventory.GroupClient {
@ -507,7 +509,8 @@ func (d *dependency) GroupClient() inventory.GroupClient {
return d.groupClient
}
return inventory.NewGroupClient(d.DBClient(), d.ConfigProvider().Database().Type, d.KV())
d.groupClient = inventory.NewGroupClient(d.DBClient(), d.ConfigProvider().Database().Type, d.KV())
return d.groupClient
}
func (d *dependency) NodeClient() inventory.NodeClient {
@ -515,7 +518,8 @@ func (d *dependency) NodeClient() inventory.NodeClient {
return d.nodeClient
}
return inventory.NewNodeClient(d.DBClient())
d.nodeClient = inventory.NewNodeClient(d.DBClient())
return d.nodeClient
}
func (d *dependency) NodePool(ctx context.Context) (cluster.NodePool, error) {
@ -557,7 +561,8 @@ func (d *dependency) OAuthClientClient() inventory.OAuthClientClient {
return d.oAuthClient
}
return inventory.NewOAuthClientClient(d.DBClient(), d.ConfigProvider().Database().Type)
d.oAuthClient = inventory.NewOAuthClientClient(d.DBClient(), d.ConfigProvider().Database().Type)
return d.oAuthClient
}
func (d *dependency) MimeDetector(ctx context.Context) mime.MimeDetector {
@ -801,7 +806,8 @@ func (d *dependency) FileClient() inventory.FileClient {
return d.fileClient
}
return inventory.NewFileClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder())
d.fileClient = inventory.NewFileClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder())
return d.fileClient
}
func (d *dependency) ShareClient() inventory.ShareClient {
@ -809,7 +815,8 @@ func (d *dependency) ShareClient() inventory.ShareClient {
return d.shareClient
}
return inventory.NewShareClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder())
d.shareClient = inventory.NewShareClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder())
return d.shareClient
}
func (d *dependency) TaskClient() inventory.TaskClient {
@ -817,7 +824,8 @@ func (d *dependency) TaskClient() inventory.TaskClient {
return d.taskClient
}
return inventory.NewTaskClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder())
d.taskClient = inventory.NewTaskClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder())
return d.taskClient
}
func (d *dependency) DavAccountClient() inventory.DavAccountClient {
@ -825,7 +833,8 @@ func (d *dependency) DavAccountClient() inventory.DavAccountClient {
return d.davAccountClient
}
return inventory.NewDavAccountClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder())
d.davAccountClient = inventory.NewDavAccountClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder())
return d.davAccountClient
}
func (d *dependency) DirectLinkClient() inventory.DirectLinkClient {
@ -833,7 +842,8 @@ func (d *dependency) DirectLinkClient() inventory.DirectLinkClient {
return d.directLinkClient
}
return inventory.NewDirectLinkClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder())
d.directLinkClient = inventory.NewDirectLinkClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder())
return d.directLinkClient
}
func (d *dependency) HashIDEncoder() hashid.Encoder {
@ -887,7 +897,8 @@ func (d *dependency) StoragePolicyClient() inventory.StoragePolicyClient {
return d.storagePolicyClient
}
return inventory.NewStoragePolicyClient(d.DBClient(), d.KV())
d.storagePolicyClient = inventory.NewStoragePolicyClient(d.DBClient(), d.KV())
return d.storagePolicyClient
}
func (d *dependency) ThumbPipeline() thumb.Generator {

@ -181,6 +181,7 @@ func (m *Migrator) Migrate() error {
// Continue from the current step
if m.state.Step <= StepSchema {
m.l.Info("Creating basic v4 table schema...")
inventory.RepairHeatWaveNotSecondary(m.l, m.v4client, context.Background())
if err := m.v4client.Schema.Create(context.Background()); err != nil {
return fmt.Errorf("failed creating schema resources: %w", err)
}

@ -36,7 +36,6 @@ func (m *Migrator) migrateShare() error {
tx, err := m.v4client.Tx(ctx)
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("failed to start transaction: %w", err)
}

File diff suppressed because one or more lines are too long

@ -419,6 +419,7 @@ var (
{Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"mysql": "datetime"}},
{Name: "name", Type: field.TypeString},
{Name: "type", Type: field.TypeString},
{Name: "status", Type: field.TypeEnum, Enums: []string{"active", "suspended"}, Default: "active"},
{Name: "server", Type: field.TypeString, Nullable: true},
{Name: "bucket_name", Type: field.TypeString, Nullable: true},
{Name: "is_private", Type: field.TypeBool, Nullable: true},
@ -438,7 +439,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "storage_policies_nodes_storage_policy",
Columns: []*schema.Column{StoragePoliciesColumns[15]},
Columns: []*schema.Column{StoragePoliciesColumns[16]},
RefColumns: []*schema.Column{NodesColumns[0]},
OnDelete: schema.SetNull,
},
@ -455,6 +456,7 @@ var (
{Name: "public_state", Type: field.TypeJSON},
{Name: "private_state", Type: field.TypeString, Nullable: true, Size: 2147483647},
{Name: "correlation_id", Type: field.TypeUUID, Nullable: true},
{Name: "hidden", Type: field.TypeBool, Default: false},
{Name: "user_tasks", Type: field.TypeInt, Nullable: true},
}
// TasksTable holds the schema information for the "tasks" table.
@ -465,7 +467,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "tasks_users_tasks",
Columns: []*schema.Column{TasksColumns[9]},
Columns: []*schema.Column{TasksColumns[10]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.SetNull,
},
@ -481,6 +483,9 @@ var (
{Name: "nick", Type: field.TypeString, Size: 100},
{Name: "password", Type: field.TypeString, Nullable: true},
{Name: "status", Type: field.TypeEnum, Enums: []string{"active", "inactive", "manual_banned", "sys_banned"}, Default: "active"},
{Name: "ban_expires", Type: field.TypeTime, Nullable: true},
{Name: "ban_reason", Type: field.TypeString, Nullable: true, Size: 2147483647},
{Name: "last_login", Type: field.TypeTime, Nullable: true},
{Name: "storage", Type: field.TypeInt64, Default: 0},
{Name: "two_factor_secret", Type: field.TypeString, Nullable: true},
{Name: "avatar", Type: field.TypeString, Nullable: true},
@ -495,7 +500,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "users_groups_users",
Columns: []*schema.Column{UsersColumns[12]},
Columns: []*schema.Column{UsersColumns[15]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.NoAction,
},

@ -12519,6 +12519,7 @@ type StoragePolicyMutation struct {
deleted_at *time.Time
name *string
_type *string
status *storagepolicy.Status
server *string
bucket_name *string
is_private *bool
@ -12837,6 +12838,42 @@ func (m *StoragePolicyMutation) ResetType() {
m._type = nil
}
// SetStatus sets the "status" field.
func (m *StoragePolicyMutation) SetStatus(s storagepolicy.Status) {
m.status = &s
}
// Status returns the value of the "status" field in the mutation.
func (m *StoragePolicyMutation) Status() (r storagepolicy.Status, exists bool) {
v := m.status
if v == nil {
return
}
return *v, true
}
// OldStatus returns the old "status" field's value of the StoragePolicy entity.
// If the StoragePolicy object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *StoragePolicyMutation) OldStatus(ctx context.Context) (v storagepolicy.Status, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldStatus is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldStatus requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldStatus: %w", err)
}
return oldValue.Status, nil
}
// ResetStatus resets all changes to the "status" field.
func (m *StoragePolicyMutation) ResetStatus() {
m.status = nil
}
// SetServer sets the "server" field.
func (m *StoragePolicyMutation) SetServer(s string) {
m.server = &s
@ -13571,7 +13608,7 @@ func (m *StoragePolicyMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *StoragePolicyMutation) Fields() []string {
fields := make([]string, 0, 15)
fields := make([]string, 0, 16)
if m.created_at != nil {
fields = append(fields, storagepolicy.FieldCreatedAt)
}
@ -13587,6 +13624,9 @@ func (m *StoragePolicyMutation) Fields() []string {
if m._type != nil {
fields = append(fields, storagepolicy.FieldType)
}
if m.status != nil {
fields = append(fields, storagepolicy.FieldStatus)
}
if m.server != nil {
fields = append(fields, storagepolicy.FieldServer)
}
@ -13635,6 +13675,8 @@ func (m *StoragePolicyMutation) Field(name string) (ent.Value, bool) {
return m.Name()
case storagepolicy.FieldType:
return m.GetType()
case storagepolicy.FieldStatus:
return m.Status()
case storagepolicy.FieldServer:
return m.Server()
case storagepolicy.FieldBucketName:
@ -13674,6 +13716,8 @@ func (m *StoragePolicyMutation) OldField(ctx context.Context, name string) (ent.
return m.OldName(ctx)
case storagepolicy.FieldType:
return m.OldType(ctx)
case storagepolicy.FieldStatus:
return m.OldStatus(ctx)
case storagepolicy.FieldServer:
return m.OldServer(ctx)
case storagepolicy.FieldBucketName:
@ -13738,6 +13782,13 @@ func (m *StoragePolicyMutation) SetField(name string, value ent.Value) error {
}
m.SetType(v)
return nil
case storagepolicy.FieldStatus:
v, ok := value.(storagepolicy.Status)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetStatus(v)
return nil
case storagepolicy.FieldServer:
v, ok := value.(string)
if !ok {
@ -13956,6 +14007,9 @@ func (m *StoragePolicyMutation) ResetField(name string) error {
case storagepolicy.FieldType:
m.ResetType()
return nil
case storagepolicy.FieldStatus:
m.ResetStatus()
return nil
case storagepolicy.FieldServer:
m.ResetServer()
return nil
@ -14158,6 +14212,7 @@ type TaskMutation struct {
public_state **types.TaskPublicState
private_state *string
correlation_id *uuid.UUID
hidden *bool
clearedFields map[string]struct{}
user *int
cleareduser bool
@ -14640,6 +14695,42 @@ func (m *TaskMutation) ResetUserTasks() {
delete(m.clearedFields, task.FieldUserTasks)
}
// SetHidden sets the "hidden" field.
func (m *TaskMutation) SetHidden(b bool) {
m.hidden = &b
}
// Hidden returns the value of the "hidden" field in the mutation.
func (m *TaskMutation) Hidden() (r bool, exists bool) {
v := m.hidden
if v == nil {
return
}
return *v, true
}
// OldHidden returns the old "hidden" field's value of the Task entity.
// If the Task object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *TaskMutation) OldHidden(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldHidden is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldHidden requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldHidden: %w", err)
}
return oldValue.Hidden, nil
}
// ResetHidden resets all changes to the "hidden" field.
func (m *TaskMutation) ResetHidden() {
m.hidden = nil
}
// SetUserID sets the "user" edge to the User entity by id.
func (m *TaskMutation) SetUserID(id int) {
m.user = &id
@ -14714,7 +14805,7 @@ func (m *TaskMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *TaskMutation) Fields() []string {
fields := make([]string, 0, 9)
fields := make([]string, 0, 10)
if m.created_at != nil {
fields = append(fields, task.FieldCreatedAt)
}
@ -14742,6 +14833,9 @@ func (m *TaskMutation) Fields() []string {
if m.user != nil {
fields = append(fields, task.FieldUserTasks)
}
if m.hidden != nil {
fields = append(fields, task.FieldHidden)
}
return fields
}
@ -14768,6 +14862,8 @@ func (m *TaskMutation) Field(name string) (ent.Value, bool) {
return m.CorrelationID()
case task.FieldUserTasks:
return m.UserTasks()
case task.FieldHidden:
return m.Hidden()
}
return nil, false
}
@ -14795,6 +14891,8 @@ func (m *TaskMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldCorrelationID(ctx)
case task.FieldUserTasks:
return m.OldUserTasks(ctx)
case task.FieldHidden:
return m.OldHidden(ctx)
}
return nil, fmt.Errorf("unknown Task field %s", name)
}
@ -14867,6 +14965,13 @@ func (m *TaskMutation) SetField(name string, value ent.Value) error {
}
m.SetUserTasks(v)
return nil
case task.FieldHidden:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetHidden(v)
return nil
}
return fmt.Errorf("unknown Task field %s", name)
}
@ -14973,6 +15078,9 @@ func (m *TaskMutation) ResetField(name string) error {
case task.FieldUserTasks:
m.ResetUserTasks()
return nil
case task.FieldHidden:
m.ResetHidden()
return nil
}
return fmt.Errorf("unknown Task field %s", name)
}
@ -15064,6 +15172,9 @@ type UserMutation struct {
nick *string
password *string
status *user.Status
ban_expires *time.Time
ban_reason *string
last_login *time.Time
storage *int64
addstorage *int64
two_factor_secret *string
@ -15477,6 +15588,153 @@ func (m *UserMutation) ResetStatus() {
m.status = nil
}
// SetBanExpires sets the "ban_expires" field.
func (m *UserMutation) SetBanExpires(t time.Time) {
m.ban_expires = &t
}
// BanExpires returns the value of the "ban_expires" field in the mutation.
func (m *UserMutation) BanExpires() (r time.Time, exists bool) {
v := m.ban_expires
if v == nil {
return
}
return *v, true
}
// OldBanExpires returns the old "ban_expires" field's value of the User entity.
// If the User object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *UserMutation) OldBanExpires(ctx context.Context) (v *time.Time, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldBanExpires is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldBanExpires requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldBanExpires: %w", err)
}
return oldValue.BanExpires, nil
}
// ClearBanExpires clears the value of the "ban_expires" field.
func (m *UserMutation) ClearBanExpires() {
m.ban_expires = nil
m.clearedFields[user.FieldBanExpires] = struct{}{}
}
// BanExpiresCleared returns if the "ban_expires" field was cleared in this mutation.
func (m *UserMutation) BanExpiresCleared() bool {
_, ok := m.clearedFields[user.FieldBanExpires]
return ok
}
// ResetBanExpires resets all changes to the "ban_expires" field.
func (m *UserMutation) ResetBanExpires() {
m.ban_expires = nil
delete(m.clearedFields, user.FieldBanExpires)
}
// SetBanReason sets the "ban_reason" field.
func (m *UserMutation) SetBanReason(s string) {
m.ban_reason = &s
}
// BanReason returns the value of the "ban_reason" field in the mutation.
func (m *UserMutation) BanReason() (r string, exists bool) {
v := m.ban_reason
if v == nil {
return
}
return *v, true
}
// OldBanReason returns the old "ban_reason" field's value of the User entity.
// If the User object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *UserMutation) OldBanReason(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldBanReason is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldBanReason requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldBanReason: %w", err)
}
return oldValue.BanReason, nil
}
// ClearBanReason clears the value of the "ban_reason" field.
func (m *UserMutation) ClearBanReason() {
m.ban_reason = nil
m.clearedFields[user.FieldBanReason] = struct{}{}
}
// BanReasonCleared returns if the "ban_reason" field was cleared in this mutation.
func (m *UserMutation) BanReasonCleared() bool {
_, ok := m.clearedFields[user.FieldBanReason]
return ok
}
// ResetBanReason resets all changes to the "ban_reason" field.
func (m *UserMutation) ResetBanReason() {
m.ban_reason = nil
delete(m.clearedFields, user.FieldBanReason)
}
// SetLastLogin sets the "last_login" field.
func (m *UserMutation) SetLastLogin(t time.Time) {
m.last_login = &t
}
// LastLogin returns the value of the "last_login" field in the mutation.
func (m *UserMutation) LastLogin() (r time.Time, exists bool) {
v := m.last_login
if v == nil {
return
}
return *v, true
}
// OldLastLogin returns the old "last_login" field's value of the User entity.
// If the User object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *UserMutation) OldLastLogin(ctx context.Context) (v *time.Time, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldLastLogin is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldLastLogin requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldLastLogin: %w", err)
}
return oldValue.LastLogin, nil
}
// ClearLastLogin clears the value of the "last_login" field.
func (m *UserMutation) ClearLastLogin() {
m.last_login = nil
m.clearedFields[user.FieldLastLogin] = struct{}{}
}
// LastLoginCleared returns if the "last_login" field was cleared in this mutation.
func (m *UserMutation) LastLoginCleared() bool {
_, ok := m.clearedFields[user.FieldLastLogin]
return ok
}
// ResetLastLogin resets all changes to the "last_login" field.
func (m *UserMutation) ResetLastLogin() {
m.last_login = nil
delete(m.clearedFields, user.FieldLastLogin)
}
// SetStorage sets the "storage" field.
func (m *UserMutation) SetStorage(i int64) {
m.storage = &i
@ -16222,7 +16480,7 @@ func (m *UserMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UserMutation) Fields() []string {
fields := make([]string, 0, 12)
fields := make([]string, 0, 15)
if m.created_at != nil {
fields = append(fields, user.FieldCreatedAt)
}
@ -16244,6 +16502,15 @@ func (m *UserMutation) Fields() []string {
if m.status != nil {
fields = append(fields, user.FieldStatus)
}
if m.ban_expires != nil {
fields = append(fields, user.FieldBanExpires)
}
if m.ban_reason != nil {
fields = append(fields, user.FieldBanReason)
}
if m.last_login != nil {
fields = append(fields, user.FieldLastLogin)
}
if m.storage != nil {
fields = append(fields, user.FieldStorage)
}
@ -16281,6 +16548,12 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
return m.Password()
case user.FieldStatus:
return m.Status()
case user.FieldBanExpires:
return m.BanExpires()
case user.FieldBanReason:
return m.BanReason()
case user.FieldLastLogin:
return m.LastLogin()
case user.FieldStorage:
return m.Storage()
case user.FieldTwoFactorSecret:
@ -16314,6 +16587,12 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldPassword(ctx)
case user.FieldStatus:
return m.OldStatus(ctx)
case user.FieldBanExpires:
return m.OldBanExpires(ctx)
case user.FieldBanReason:
return m.OldBanReason(ctx)
case user.FieldLastLogin:
return m.OldLastLogin(ctx)
case user.FieldStorage:
return m.OldStorage(ctx)
case user.FieldTwoFactorSecret:
@ -16382,6 +16661,27 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
}
m.SetStatus(v)
return nil
case user.FieldBanExpires:
v, ok := value.(time.Time)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetBanExpires(v)
return nil
case user.FieldBanReason:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetBanReason(v)
return nil
case user.FieldLastLogin:
v, ok := value.(time.Time)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetLastLogin(v)
return nil
case user.FieldStorage:
v, ok := value.(int64)
if !ok {
@ -16468,6 +16768,15 @@ func (m *UserMutation) ClearedFields() []string {
if m.FieldCleared(user.FieldPassword) {
fields = append(fields, user.FieldPassword)
}
if m.FieldCleared(user.FieldBanExpires) {
fields = append(fields, user.FieldBanExpires)
}
if m.FieldCleared(user.FieldBanReason) {
fields = append(fields, user.FieldBanReason)
}
if m.FieldCleared(user.FieldLastLogin) {
fields = append(fields, user.FieldLastLogin)
}
if m.FieldCleared(user.FieldTwoFactorSecret) {
fields = append(fields, user.FieldTwoFactorSecret)
}
@ -16497,6 +16806,15 @@ func (m *UserMutation) ClearField(name string) error {
case user.FieldPassword:
m.ClearPassword()
return nil
case user.FieldBanExpires:
m.ClearBanExpires()
return nil
case user.FieldBanReason:
m.ClearBanReason()
return nil
case user.FieldLastLogin:
m.ClearLastLogin()
return nil
case user.FieldTwoFactorSecret:
m.ClearTwoFactorSecret()
return nil
@ -16535,6 +16853,15 @@ func (m *UserMutation) ResetField(name string) error {
case user.FieldStatus:
m.ResetStatus()
return nil
case user.FieldBanExpires:
m.ResetBanExpires()
return nil
case user.FieldBanReason:
m.ResetBanReason()
return nil
case user.FieldLastLogin:
m.ResetLastLogin()
return nil
case user.FieldStorage:
m.ResetStorage()
return nil

@ -361,7 +361,7 @@ func init() {
// storagepolicy.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
storagepolicy.UpdateDefaultUpdatedAt = storagepolicyDescUpdatedAt.UpdateDefault.(func() time.Time)
// storagepolicyDescSettings is the schema descriptor for settings field.
storagepolicyDescSettings := storagepolicyFields[10].Descriptor()
storagepolicyDescSettings := storagepolicyFields[11].Descriptor()
// storagepolicy.DefaultSettings holds the default value on creation for the settings field.
storagepolicy.DefaultSettings = storagepolicyDescSettings.Default.(*types.PolicySetting)
taskMixin := schema.Task{}.Mixin()
@ -383,6 +383,10 @@ func init() {
task.DefaultUpdatedAt = taskDescUpdatedAt.Default.(func() time.Time)
// task.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
task.UpdateDefaultUpdatedAt = taskDescUpdatedAt.UpdateDefault.(func() time.Time)
// taskDescHidden is the schema descriptor for hidden field.
taskDescHidden := taskFields[6].Descriptor()
// task.DefaultHidden holds the default value on creation for the hidden field.
task.DefaultHidden = taskDescHidden.Default.(bool)
userMixin := schema.User{}.Mixin()
userMixinHooks0 := userMixin[0].Hooks()
user.Hooks[0] = userMixinHooks0[0]
@ -411,11 +415,11 @@ func init() {
// user.NickValidator is a validator for the "nick" field. It is called by the builders before save.
user.NickValidator = userDescNick.Validators[0].(func(string) error)
// userDescStorage is the schema descriptor for storage field.
userDescStorage := userFields[4].Descriptor()
userDescStorage := userFields[7].Descriptor()
// user.DefaultStorage holds the default value on creation for the storage field.
user.DefaultStorage = userDescStorage.Default.(int64)
// userDescSettings is the schema descriptor for settings field.
userDescSettings := userFields[7].Descriptor()
userDescSettings := userFields[10].Descriptor()
// user.DefaultSettings holds the default value on creation for the settings field.
user.DefaultSettings = userDescSettings.Default.(*types.UserSetting)
}

@ -16,6 +16,10 @@ func (StoragePolicy) Fields() []ent.Field {
return []ent.Field{
field.String("name"),
field.String("type"),
// Suspended policies keep serving reads but reject new uploads.
field.Enum("status").
Values("active", "suspended").
Default("active"),
field.String("server").
Optional(),
field.String("bucket_name").

@ -26,6 +26,8 @@ func (Task) Fields() []ent.Field {
Optional().
Immutable(),
field.Int("user_tasks").Optional(),
// hidden is set when the owner deletes the task record; admin views keep it.
field.Bool("hidden").Default(false),
}
}

@ -25,6 +25,17 @@ func (User) Fields() []ent.Field {
field.Enum("status").
Values("active", "inactive", "manual_banned", "sys_banned").
Default("active"),
// ban_expires lifts a banned status after this time; nil = permanent.
field.Time("ban_expires").
Optional().
Nillable(),
// ban_reason is shown to the user when a banned login is rejected.
field.Text("ban_reason").
Optional(),
field.Time("last_login").
Optional().
Nillable().
Comment("Time of the last successful sign-in"),
field.Int64("storage").
Default(0),
field.String("two_factor_secret").

@ -30,6 +30,8 @@ type StoragePolicy struct {
Name string `json:"name,omitempty"`
// Type holds the value of the "type" field.
Type string `json:"type,omitempty"`
// Status holds the value of the "status" field.
Status storagepolicy.Status `json:"status,omitempty"`
// Server holds the value of the "server" field.
Server string `json:"server,omitempty"`
// BucketName holds the value of the "bucket_name" field.
@ -122,7 +124,7 @@ func (*StoragePolicy) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullBool)
case storagepolicy.FieldID, storagepolicy.FieldMaxSize, storagepolicy.FieldNodeID:
values[i] = new(sql.NullInt64)
case storagepolicy.FieldName, storagepolicy.FieldType, storagepolicy.FieldServer, storagepolicy.FieldBucketName, storagepolicy.FieldAccessKey, storagepolicy.FieldSecretKey, storagepolicy.FieldDirNameRule, storagepolicy.FieldFileNameRule:
case storagepolicy.FieldName, storagepolicy.FieldType, storagepolicy.FieldStatus, storagepolicy.FieldServer, storagepolicy.FieldBucketName, storagepolicy.FieldAccessKey, storagepolicy.FieldSecretKey, storagepolicy.FieldDirNameRule, storagepolicy.FieldFileNameRule:
values[i] = new(sql.NullString)
case storagepolicy.FieldCreatedAt, storagepolicy.FieldUpdatedAt, storagepolicy.FieldDeletedAt:
values[i] = new(sql.NullTime)
@ -178,6 +180,12 @@ func (sp *StoragePolicy) assignValues(columns []string, values []any) error {
} else if value.Valid {
sp.Type = value.String
}
case storagepolicy.FieldStatus:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field status", values[i])
} else if value.Valid {
sp.Status = storagepolicy.Status(value.String)
}
case storagepolicy.FieldServer:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field server", values[i])
@ -313,6 +321,9 @@ func (sp *StoragePolicy) String() string {
builder.WriteString("type=")
builder.WriteString(sp.Type)
builder.WriteString(", ")
builder.WriteString("status=")
builder.WriteString(fmt.Sprintf("%v", sp.Status))
builder.WriteString(", ")
builder.WriteString("server=")
builder.WriteString(sp.Server)
builder.WriteString(", ")

@ -3,6 +3,7 @@
package storagepolicy
import (
"fmt"
"time"
"entgo.io/ent"
@ -26,6 +27,8 @@ const (
FieldName = "name"
// FieldType holds the string denoting the type field in the database.
FieldType = "type"
// FieldStatus holds the string denoting the status field in the database.
FieldStatus = "status"
// FieldServer holds the string denoting the server field in the database.
FieldServer = "server"
// FieldBucketName holds the string denoting the bucket_name field in the database.
@ -94,6 +97,7 @@ var Columns = []string{
FieldDeletedAt,
FieldName,
FieldType,
FieldStatus,
FieldServer,
FieldBucketName,
FieldIsPrivate,
@ -134,6 +138,32 @@ var (
DefaultSettings *types.PolicySetting
)
// Status defines the type for the "status" enum field.
type Status string
// StatusActive is the default value of the Status enum.
const DefaultStatus = StatusActive
// Status values.
const (
StatusActive Status = "active"
StatusSuspended Status = "suspended"
)
func (s Status) String() string {
return string(s)
}
// StatusValidator is a validator for the "status" field enum values. It is called by the builders before save.
func StatusValidator(s Status) error {
switch s {
case StatusActive, StatusSuspended:
return nil
default:
return fmt.Errorf("storagepolicy: invalid enum value for status field: %q", s)
}
}
// OrderOption defines the ordering options for the StoragePolicy queries.
type OrderOption func(*sql.Selector)
@ -167,6 +197,11 @@ func ByType(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldType, opts...).ToFunc()
}
// ByStatus orders the results by the status field.
func ByStatus(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldStatus, opts...).ToFunc()
}
// ByServer orders the results by the server field.
func ByServer(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldServer, opts...).ToFunc()

@ -385,6 +385,26 @@ func TypeContainsFold(v string) predicate.StoragePolicy {
return predicate.StoragePolicy(sql.FieldContainsFold(FieldType, v))
}
// StatusEQ applies the EQ predicate on the "status" field.
func StatusEQ(v Status) predicate.StoragePolicy {
return predicate.StoragePolicy(sql.FieldEQ(FieldStatus, v))
}
// StatusNEQ applies the NEQ predicate on the "status" field.
func StatusNEQ(v Status) predicate.StoragePolicy {
return predicate.StoragePolicy(sql.FieldNEQ(FieldStatus, v))
}
// StatusIn applies the In predicate on the "status" field.
func StatusIn(vs ...Status) predicate.StoragePolicy {
return predicate.StoragePolicy(sql.FieldIn(FieldStatus, vs...))
}
// StatusNotIn applies the NotIn predicate on the "status" field.
func StatusNotIn(vs ...Status) predicate.StoragePolicy {
return predicate.StoragePolicy(sql.FieldNotIn(FieldStatus, vs...))
}
// ServerEQ applies the EQ predicate on the "server" field.
func ServerEQ(v string) predicate.StoragePolicy {
return predicate.StoragePolicy(sql.FieldEQ(FieldServer, v))

@ -81,6 +81,20 @@ func (spc *StoragePolicyCreate) SetType(s string) *StoragePolicyCreate {
return spc
}
// SetStatus sets the "status" field.
func (spc *StoragePolicyCreate) SetStatus(s storagepolicy.Status) *StoragePolicyCreate {
spc.mutation.SetStatus(s)
return spc
}
// SetNillableStatus sets the "status" field if the given value is not nil.
func (spc *StoragePolicyCreate) SetNillableStatus(s *storagepolicy.Status) *StoragePolicyCreate {
if s != nil {
spc.SetStatus(*s)
}
return spc
}
// SetServer sets the "server" field.
func (spc *StoragePolicyCreate) SetServer(s string) *StoragePolicyCreate {
spc.mutation.SetServer(s)
@ -314,6 +328,10 @@ func (spc *StoragePolicyCreate) defaults() error {
v := storagepolicy.DefaultUpdatedAt()
spc.mutation.SetUpdatedAt(v)
}
if _, ok := spc.mutation.Status(); !ok {
v := storagepolicy.DefaultStatus
spc.mutation.SetStatus(v)
}
if _, ok := spc.mutation.Settings(); !ok {
v := storagepolicy.DefaultSettings
spc.mutation.SetSettings(v)
@ -335,6 +353,14 @@ func (spc *StoragePolicyCreate) check() error {
if _, ok := spc.mutation.GetType(); !ok {
return &ValidationError{Name: "type", err: errors.New(`ent: missing required field "StoragePolicy.type"`)}
}
if _, ok := spc.mutation.Status(); !ok {
return &ValidationError{Name: "status", err: errors.New(`ent: missing required field "StoragePolicy.status"`)}
}
if v, ok := spc.mutation.Status(); ok {
if err := storagepolicy.StatusValidator(v); err != nil {
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "StoragePolicy.status": %w`, err)}
}
}
return nil
}
@ -389,6 +415,10 @@ func (spc *StoragePolicyCreate) createSpec() (*StoragePolicy, *sqlgraph.CreateSp
_spec.SetField(storagepolicy.FieldType, field.TypeString, value)
_node.Type = value
}
if value, ok := spc.mutation.Status(); ok {
_spec.SetField(storagepolicy.FieldStatus, field.TypeEnum, value)
_node.Status = value
}
if value, ok := spc.mutation.Server(); ok {
_spec.SetField(storagepolicy.FieldServer, field.TypeString, value)
_node.Server = value
@ -596,6 +626,18 @@ func (u *StoragePolicyUpsert) UpdateType() *StoragePolicyUpsert {
return u
}
// SetStatus sets the "status" field.
func (u *StoragePolicyUpsert) SetStatus(v storagepolicy.Status) *StoragePolicyUpsert {
u.Set(storagepolicy.FieldStatus, v)
return u
}
// UpdateStatus sets the "status" field to the value that was provided on create.
func (u *StoragePolicyUpsert) UpdateStatus() *StoragePolicyUpsert {
u.SetExcluded(storagepolicy.FieldStatus)
return u
}
// SetServer sets the "server" field.
func (u *StoragePolicyUpsert) SetServer(v string) *StoragePolicyUpsert {
u.Set(storagepolicy.FieldServer, v)
@ -890,6 +932,20 @@ func (u *StoragePolicyUpsertOne) UpdateType() *StoragePolicyUpsertOne {
})
}
// SetStatus sets the "status" field.
func (u *StoragePolicyUpsertOne) SetStatus(v storagepolicy.Status) *StoragePolicyUpsertOne {
return u.Update(func(s *StoragePolicyUpsert) {
s.SetStatus(v)
})
}
// UpdateStatus sets the "status" field to the value that was provided on create.
func (u *StoragePolicyUpsertOne) UpdateStatus() *StoragePolicyUpsertOne {
return u.Update(func(s *StoragePolicyUpsert) {
s.UpdateStatus()
})
}
// SetServer sets the "server" field.
func (u *StoragePolicyUpsertOne) SetServer(v string) *StoragePolicyUpsertOne {
return u.Update(func(s *StoragePolicyUpsert) {
@ -1386,6 +1442,20 @@ func (u *StoragePolicyUpsertBulk) UpdateType() *StoragePolicyUpsertBulk {
})
}
// SetStatus sets the "status" field.
func (u *StoragePolicyUpsertBulk) SetStatus(v storagepolicy.Status) *StoragePolicyUpsertBulk {
return u.Update(func(s *StoragePolicyUpsert) {
s.SetStatus(v)
})
}
// UpdateStatus sets the "status" field to the value that was provided on create.
func (u *StoragePolicyUpsertBulk) UpdateStatus() *StoragePolicyUpsertBulk {
return u.Update(func(s *StoragePolicyUpsert) {
s.UpdateStatus()
})
}
// SetServer sets the "server" field.
func (u *StoragePolicyUpsertBulk) SetServer(v string) *StoragePolicyUpsertBulk {
return u.Update(func(s *StoragePolicyUpsert) {

@ -87,6 +87,20 @@ func (spu *StoragePolicyUpdate) SetNillableType(s *string) *StoragePolicyUpdate
return spu
}
// SetStatus sets the "status" field.
func (spu *StoragePolicyUpdate) SetStatus(s storagepolicy.Status) *StoragePolicyUpdate {
spu.mutation.SetStatus(s)
return spu
}
// SetNillableStatus sets the "status" field if the given value is not nil.
func (spu *StoragePolicyUpdate) SetNillableStatus(s *storagepolicy.Status) *StoragePolicyUpdate {
if s != nil {
spu.SetStatus(*s)
}
return spu
}
// SetServer sets the "server" field.
func (spu *StoragePolicyUpdate) SetServer(s string) *StoragePolicyUpdate {
spu.mutation.SetServer(s)
@ -452,7 +466,20 @@ func (spu *StoragePolicyUpdate) defaults() error {
return nil
}
// check runs all checks and user-defined validators on the builder.
func (spu *StoragePolicyUpdate) check() error {
if v, ok := spu.mutation.Status(); ok {
if err := storagepolicy.StatusValidator(v); err != nil {
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "StoragePolicy.status": %w`, err)}
}
}
return nil
}
func (spu *StoragePolicyUpdate) sqlSave(ctx context.Context) (n int, err error) {
if err := spu.check(); err != nil {
return n, err
}
_spec := sqlgraph.NewUpdateSpec(storagepolicy.Table, storagepolicy.Columns, sqlgraph.NewFieldSpec(storagepolicy.FieldID, field.TypeInt))
if ps := spu.mutation.predicates; len(ps) > 0 {
_spec.Predicate = func(selector *sql.Selector) {
@ -476,6 +503,9 @@ func (spu *StoragePolicyUpdate) sqlSave(ctx context.Context) (n int, err error)
if value, ok := spu.mutation.GetType(); ok {
_spec.SetField(storagepolicy.FieldType, field.TypeString, value)
}
if value, ok := spu.mutation.Status(); ok {
_spec.SetField(storagepolicy.FieldStatus, field.TypeEnum, value)
}
if value, ok := spu.mutation.Server(); ok {
_spec.SetField(storagepolicy.FieldServer, field.TypeString, value)
}
@ -771,6 +801,20 @@ func (spuo *StoragePolicyUpdateOne) SetNillableType(s *string) *StoragePolicyUpd
return spuo
}
// SetStatus sets the "status" field.
func (spuo *StoragePolicyUpdateOne) SetStatus(s storagepolicy.Status) *StoragePolicyUpdateOne {
spuo.mutation.SetStatus(s)
return spuo
}
// SetNillableStatus sets the "status" field if the given value is not nil.
func (spuo *StoragePolicyUpdateOne) SetNillableStatus(s *storagepolicy.Status) *StoragePolicyUpdateOne {
if s != nil {
spuo.SetStatus(*s)
}
return spuo
}
// SetServer sets the "server" field.
func (spuo *StoragePolicyUpdateOne) SetServer(s string) *StoragePolicyUpdateOne {
spuo.mutation.SetServer(s)
@ -1149,7 +1193,20 @@ func (spuo *StoragePolicyUpdateOne) defaults() error {
return nil
}
// check runs all checks and user-defined validators on the builder.
func (spuo *StoragePolicyUpdateOne) check() error {
if v, ok := spuo.mutation.Status(); ok {
if err := storagepolicy.StatusValidator(v); err != nil {
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "StoragePolicy.status": %w`, err)}
}
}
return nil
}
func (spuo *StoragePolicyUpdateOne) sqlSave(ctx context.Context) (_node *StoragePolicy, err error) {
if err := spuo.check(); err != nil {
return _node, err
}
_spec := sqlgraph.NewUpdateSpec(storagepolicy.Table, storagepolicy.Columns, sqlgraph.NewFieldSpec(storagepolicy.FieldID, field.TypeInt))
id, ok := spuo.mutation.ID()
if !ok {
@ -1190,6 +1247,9 @@ func (spuo *StoragePolicyUpdateOne) sqlSave(ctx context.Context) (_node *Storage
if value, ok := spuo.mutation.GetType(); ok {
_spec.SetField(storagepolicy.FieldType, field.TypeString, value)
}
if value, ok := spuo.mutation.Status(); ok {
_spec.SetField(storagepolicy.FieldStatus, field.TypeEnum, value)
}
if value, ok := spuo.mutation.Server(); ok {
_spec.SetField(storagepolicy.FieldServer, field.TypeString, value)
}

@ -39,6 +39,8 @@ type Task struct {
CorrelationID uuid.UUID `json:"correlation_id,omitempty"`
// UserTasks holds the value of the "user_tasks" field.
UserTasks int `json:"user_tasks,omitempty"`
// Hidden holds the value of the "hidden" field.
Hidden bool `json:"hidden,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the TaskQuery when eager-loading is set.
Edges TaskEdges `json:"edges"`
@ -74,6 +76,8 @@ func (*Task) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case task.FieldPublicState:
values[i] = new([]byte)
case task.FieldHidden:
values[i] = new(sql.NullBool)
case task.FieldID, task.FieldUserTasks:
values[i] = new(sql.NullInt64)
case task.FieldType, task.FieldStatus, task.FieldPrivateState:
@ -160,6 +164,12 @@ func (t *Task) assignValues(columns []string, values []any) error {
} else if value.Valid {
t.UserTasks = int(value.Int64)
}
case task.FieldHidden:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field hidden", values[i])
} else if value.Valid {
t.Hidden = value.Bool
}
default:
t.selectValues.Set(columns[i], values[i])
}
@ -229,6 +239,9 @@ func (t *Task) String() string {
builder.WriteString(", ")
builder.WriteString("user_tasks=")
builder.WriteString(fmt.Sprintf("%v", t.UserTasks))
builder.WriteString(", ")
builder.WriteString("hidden=")
builder.WriteString(fmt.Sprintf("%v", t.Hidden))
builder.WriteByte(')')
return builder.String()
}

@ -34,6 +34,8 @@ const (
FieldCorrelationID = "correlation_id"
// FieldUserTasks holds the string denoting the user_tasks field in the database.
FieldUserTasks = "user_tasks"
// FieldHidden holds the string denoting the hidden field in the database.
FieldHidden = "hidden"
// EdgeUser holds the string denoting the user edge name in mutations.
EdgeUser = "user"
// Table holds the table name of the task in the database.
@ -59,6 +61,7 @@ var Columns = []string{
FieldPrivateState,
FieldCorrelationID,
FieldUserTasks,
FieldHidden,
}
// ValidColumn reports if the column name is valid (part of the table columns).
@ -85,6 +88,8 @@ var (
DefaultUpdatedAt func() time.Time
// UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field.
UpdateDefaultUpdatedAt func() time.Time
// DefaultHidden holds the default value on creation for the "hidden" field.
DefaultHidden bool
)
// Status defines the type for the "status" enum field.
@ -165,6 +170,11 @@ func ByUserTasks(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUserTasks, opts...).ToFunc()
}
// ByHidden orders the results by the hidden field.
func ByHidden(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldHidden, opts...).ToFunc()
}
// ByUserField orders the results by user field.
func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {

@ -91,6 +91,11 @@ func UserTasks(v int) predicate.Task {
return predicate.Task(sql.FieldEQ(FieldUserTasks, v))
}
// Hidden applies equality check predicate on the "hidden" field. It's identical to HiddenEQ.
func Hidden(v bool) predicate.Task {
return predicate.Task(sql.FieldEQ(FieldHidden, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.Task {
return predicate.Task(sql.FieldEQ(FieldCreatedAt, v))
@ -461,6 +466,16 @@ func UserTasksNotNil() predicate.Task {
return predicate.Task(sql.FieldNotNull(FieldUserTasks))
}
// HiddenEQ applies the EQ predicate on the "hidden" field.
func HiddenEQ(v bool) predicate.Task {
return predicate.Task(sql.FieldEQ(FieldHidden, v))
}
// HiddenNEQ applies the NEQ predicate on the "hidden" field.
func HiddenNEQ(v bool) predicate.Task {
return predicate.Task(sql.FieldNEQ(FieldHidden, v))
}
// HasUser applies the HasEdge predicate on the "user" edge.
func HasUser() predicate.Task {
return predicate.Task(func(s *sql.Selector) {

@ -135,6 +135,20 @@ func (tc *TaskCreate) SetNillableUserTasks(i *int) *TaskCreate {
return tc
}
// SetHidden sets the "hidden" field.
func (tc *TaskCreate) SetHidden(b bool) *TaskCreate {
tc.mutation.SetHidden(b)
return tc
}
// SetNillableHidden sets the "hidden" field if the given value is not nil.
func (tc *TaskCreate) SetNillableHidden(b *bool) *TaskCreate {
if b != nil {
tc.SetHidden(*b)
}
return tc
}
// SetUserID sets the "user" edge to the User entity by ID.
func (tc *TaskCreate) SetUserID(id int) *TaskCreate {
tc.mutation.SetUserID(id)
@ -209,6 +223,10 @@ func (tc *TaskCreate) defaults() error {
v := task.DefaultStatus
tc.mutation.SetStatus(v)
}
if _, ok := tc.mutation.Hidden(); !ok {
v := task.DefaultHidden
tc.mutation.SetHidden(v)
}
return nil
}
@ -234,6 +252,9 @@ func (tc *TaskCreate) check() error {
if _, ok := tc.mutation.PublicState(); !ok {
return &ValidationError{Name: "public_state", err: errors.New(`ent: missing required field "Task.public_state"`)}
}
if _, ok := tc.mutation.Hidden(); !ok {
return &ValidationError{Name: "hidden", err: errors.New(`ent: missing required field "Task.hidden"`)}
}
return nil
}
@ -300,6 +321,10 @@ func (tc *TaskCreate) createSpec() (*Task, *sqlgraph.CreateSpec) {
_spec.SetField(task.FieldCorrelationID, field.TypeUUID, value)
_node.CorrelationID = value
}
if value, ok := tc.mutation.Hidden(); ok {
_spec.SetField(task.FieldHidden, field.TypeBool, value)
_node.Hidden = value
}
if nodes := tc.mutation.UserIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@ -471,6 +496,18 @@ func (u *TaskUpsert) ClearUserTasks() *TaskUpsert {
return u
}
// SetHidden sets the "hidden" field.
func (u *TaskUpsert) SetHidden(v bool) *TaskUpsert {
u.Set(task.FieldHidden, v)
return u
}
// UpdateHidden sets the "hidden" field to the value that was provided on create.
func (u *TaskUpsert) UpdateHidden() *TaskUpsert {
u.SetExcluded(task.FieldHidden)
return u
}
// UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using:
//
@ -638,6 +675,20 @@ func (u *TaskUpsertOne) ClearUserTasks() *TaskUpsertOne {
})
}
// SetHidden sets the "hidden" field.
func (u *TaskUpsertOne) SetHidden(v bool) *TaskUpsertOne {
return u.Update(func(s *TaskUpsert) {
s.SetHidden(v)
})
}
// UpdateHidden sets the "hidden" field to the value that was provided on create.
func (u *TaskUpsertOne) UpdateHidden() *TaskUpsertOne {
return u.Update(func(s *TaskUpsert) {
s.UpdateHidden()
})
}
// Exec executes the query.
func (u *TaskUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 {
@ -976,6 +1027,20 @@ func (u *TaskUpsertBulk) ClearUserTasks() *TaskUpsertBulk {
})
}
// SetHidden sets the "hidden" field.
func (u *TaskUpsertBulk) SetHidden(v bool) *TaskUpsertBulk {
return u.Update(func(s *TaskUpsert) {
s.SetHidden(v)
})
}
// UpdateHidden sets the "hidden" field to the value that was provided on create.
func (u *TaskUpsertBulk) UpdateHidden() *TaskUpsertBulk {
return u.Update(func(s *TaskUpsert) {
s.UpdateHidden()
})
}
// Exec executes the query.
func (u *TaskUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil {

@ -130,6 +130,20 @@ func (tu *TaskUpdate) ClearUserTasks() *TaskUpdate {
return tu
}
// SetHidden sets the "hidden" field.
func (tu *TaskUpdate) SetHidden(b bool) *TaskUpdate {
tu.mutation.SetHidden(b)
return tu
}
// SetNillableHidden sets the "hidden" field if the given value is not nil.
func (tu *TaskUpdate) SetNillableHidden(b *bool) *TaskUpdate {
if b != nil {
tu.SetHidden(*b)
}
return tu
}
// SetUserID sets the "user" edge to the User entity by ID.
func (tu *TaskUpdate) SetUserID(id int) *TaskUpdate {
tu.mutation.SetUserID(id)
@ -251,6 +265,9 @@ func (tu *TaskUpdate) sqlSave(ctx context.Context) (n int, err error) {
if tu.mutation.CorrelationIDCleared() {
_spec.ClearField(task.FieldCorrelationID, field.TypeUUID)
}
if value, ok := tu.mutation.Hidden(); ok {
_spec.SetField(task.FieldHidden, field.TypeBool, value)
}
if tu.mutation.UserCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@ -400,6 +417,20 @@ func (tuo *TaskUpdateOne) ClearUserTasks() *TaskUpdateOne {
return tuo
}
// SetHidden sets the "hidden" field.
func (tuo *TaskUpdateOne) SetHidden(b bool) *TaskUpdateOne {
tuo.mutation.SetHidden(b)
return tuo
}
// SetNillableHidden sets the "hidden" field if the given value is not nil.
func (tuo *TaskUpdateOne) SetNillableHidden(b *bool) *TaskUpdateOne {
if b != nil {
tuo.SetHidden(*b)
}
return tuo
}
// SetUserID sets the "user" edge to the User entity by ID.
func (tuo *TaskUpdateOne) SetUserID(id int) *TaskUpdateOne {
tuo.mutation.SetUserID(id)
@ -551,6 +582,9 @@ func (tuo *TaskUpdateOne) sqlSave(ctx context.Context) (_node *Task, err error)
if tuo.mutation.CorrelationIDCleared() {
_spec.ClearField(task.FieldCorrelationID, field.TypeUUID)
}
if value, ok := tuo.mutation.Hidden(); ok {
_spec.SetField(task.FieldHidden, field.TypeBool, value)
}
if tuo.mutation.UserCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,

@ -34,6 +34,12 @@ type User struct {
Password string `json:"-"`
// Status holds the value of the "status" field.
Status user.Status `json:"status,omitempty"`
// BanExpires holds the value of the "ban_expires" field.
BanExpires *time.Time `json:"ban_expires,omitempty"`
// BanReason holds the value of the "ban_reason" field.
BanReason string `json:"ban_reason,omitempty"`
// Time of the last successful sign-in
LastLogin *time.Time `json:"last_login,omitempty"`
// Storage holds the value of the "storage" field.
Storage int64 `json:"storage,omitempty"`
// TwoFactorSecret holds the value of the "two_factor_secret" field.
@ -169,9 +175,9 @@ func (*User) scanValues(columns []string) ([]any, error) {
values[i] = new([]byte)
case user.FieldID, user.FieldStorage, user.FieldGroupUsers:
values[i] = new(sql.NullInt64)
case user.FieldEmail, user.FieldNick, user.FieldPassword, user.FieldStatus, user.FieldTwoFactorSecret, user.FieldAvatar:
case user.FieldEmail, user.FieldNick, user.FieldPassword, user.FieldStatus, user.FieldBanReason, user.FieldTwoFactorSecret, user.FieldAvatar:
values[i] = new(sql.NullString)
case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt:
case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt, user.FieldBanExpires, user.FieldLastLogin:
values[i] = new(sql.NullTime)
default:
values[i] = new(sql.UnknownType)
@ -237,6 +243,26 @@ func (u *User) assignValues(columns []string, values []any) error {
} else if value.Valid {
u.Status = user.Status(value.String)
}
case user.FieldBanExpires:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field ban_expires", values[i])
} else if value.Valid {
u.BanExpires = new(time.Time)
*u.BanExpires = value.Time
}
case user.FieldBanReason:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field ban_reason", values[i])
} else if value.Valid {
u.BanReason = value.String
}
case user.FieldLastLogin:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field last_login", values[i])
} else if value.Valid {
u.LastLogin = new(time.Time)
*u.LastLogin = value.Time
}
case user.FieldStorage:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field storage", values[i])
@ -372,6 +398,19 @@ func (u *User) String() string {
builder.WriteString("status=")
builder.WriteString(fmt.Sprintf("%v", u.Status))
builder.WriteString(", ")
if v := u.BanExpires; v != nil {
builder.WriteString("ban_expires=")
builder.WriteString(v.Format(time.ANSIC))
}
builder.WriteString(", ")
builder.WriteString("ban_reason=")
builder.WriteString(u.BanReason)
builder.WriteString(", ")
if v := u.LastLogin; v != nil {
builder.WriteString("last_login=")
builder.WriteString(v.Format(time.ANSIC))
}
builder.WriteString(", ")
builder.WriteString("storage=")
builder.WriteString(fmt.Sprintf("%v", u.Storage))
builder.WriteString(", ")

@ -31,6 +31,12 @@ const (
FieldPassword = "password"
// FieldStatus holds the string denoting the status field in the database.
FieldStatus = "status"
// FieldBanExpires holds the string denoting the ban_expires field in the database.
FieldBanExpires = "ban_expires"
// FieldBanReason holds the string denoting the ban_reason field in the database.
FieldBanReason = "ban_reason"
// FieldLastLogin holds the string denoting the last_login field in the database.
FieldLastLogin = "last_login"
// FieldStorage holds the string denoting the storage field in the database.
FieldStorage = "storage"
// FieldTwoFactorSecret holds the string denoting the two_factor_secret field in the database.
@ -136,6 +142,9 @@ var Columns = []string{
FieldNick,
FieldPassword,
FieldStatus,
FieldBanExpires,
FieldBanReason,
FieldLastLogin,
FieldStorage,
FieldTwoFactorSecret,
FieldAvatar,
@ -248,6 +257,21 @@ func ByStatus(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldStatus, opts...).ToFunc()
}
// ByBanExpires orders the results by the ban_expires field.
func ByBanExpires(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldBanExpires, opts...).ToFunc()
}
// ByBanReason orders the results by the ban_reason field.
func ByBanReason(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldBanReason, opts...).ToFunc()
}
// ByLastLogin orders the results by the last_login field.
func ByLastLogin(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldLastLogin, opts...).ToFunc()
}
// ByStorage orders the results by the storage field.
func ByStorage(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldStorage, opts...).ToFunc()

@ -85,6 +85,21 @@ func Password(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldPassword, v))
}
// BanExpires applies equality check predicate on the "ban_expires" field. It's identical to BanExpiresEQ.
func BanExpires(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldBanExpires, v))
}
// BanReason applies equality check predicate on the "ban_reason" field. It's identical to BanReasonEQ.
func BanReason(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldBanReason, v))
}
// LastLogin applies equality check predicate on the "last_login" field. It's identical to LastLoginEQ.
func LastLogin(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldLastLogin, v))
}
// Storage applies equality check predicate on the "storage" field. It's identical to StorageEQ.
func Storage(v int64) predicate.User {
return predicate.User(sql.FieldEQ(FieldStorage, v))
@ -460,6 +475,181 @@ func StatusNotIn(vs ...Status) predicate.User {
return predicate.User(sql.FieldNotIn(FieldStatus, vs...))
}
// BanExpiresEQ applies the EQ predicate on the "ban_expires" field.
func BanExpiresEQ(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldBanExpires, v))
}
// BanExpiresNEQ applies the NEQ predicate on the "ban_expires" field.
func BanExpiresNEQ(v time.Time) predicate.User {
return predicate.User(sql.FieldNEQ(FieldBanExpires, v))
}
// BanExpiresIn applies the In predicate on the "ban_expires" field.
func BanExpiresIn(vs ...time.Time) predicate.User {
return predicate.User(sql.FieldIn(FieldBanExpires, vs...))
}
// BanExpiresNotIn applies the NotIn predicate on the "ban_expires" field.
func BanExpiresNotIn(vs ...time.Time) predicate.User {
return predicate.User(sql.FieldNotIn(FieldBanExpires, vs...))
}
// BanExpiresGT applies the GT predicate on the "ban_expires" field.
func BanExpiresGT(v time.Time) predicate.User {
return predicate.User(sql.FieldGT(FieldBanExpires, v))
}
// BanExpiresGTE applies the GTE predicate on the "ban_expires" field.
func BanExpiresGTE(v time.Time) predicate.User {
return predicate.User(sql.FieldGTE(FieldBanExpires, v))
}
// BanExpiresLT applies the LT predicate on the "ban_expires" field.
func BanExpiresLT(v time.Time) predicate.User {
return predicate.User(sql.FieldLT(FieldBanExpires, v))
}
// BanExpiresLTE applies the LTE predicate on the "ban_expires" field.
func BanExpiresLTE(v time.Time) predicate.User {
return predicate.User(sql.FieldLTE(FieldBanExpires, v))
}
// BanExpiresIsNil applies the IsNil predicate on the "ban_expires" field.
func BanExpiresIsNil() predicate.User {
return predicate.User(sql.FieldIsNull(FieldBanExpires))
}
// BanExpiresNotNil applies the NotNil predicate on the "ban_expires" field.
func BanExpiresNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldBanExpires))
}
// BanReasonEQ applies the EQ predicate on the "ban_reason" field.
func BanReasonEQ(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldBanReason, v))
}
// BanReasonNEQ applies the NEQ predicate on the "ban_reason" field.
func BanReasonNEQ(v string) predicate.User {
return predicate.User(sql.FieldNEQ(FieldBanReason, v))
}
// BanReasonIn applies the In predicate on the "ban_reason" field.
func BanReasonIn(vs ...string) predicate.User {
return predicate.User(sql.FieldIn(FieldBanReason, vs...))
}
// BanReasonNotIn applies the NotIn predicate on the "ban_reason" field.
func BanReasonNotIn(vs ...string) predicate.User {
return predicate.User(sql.FieldNotIn(FieldBanReason, vs...))
}
// BanReasonGT applies the GT predicate on the "ban_reason" field.
func BanReasonGT(v string) predicate.User {
return predicate.User(sql.FieldGT(FieldBanReason, v))
}
// BanReasonGTE applies the GTE predicate on the "ban_reason" field.
func BanReasonGTE(v string) predicate.User {
return predicate.User(sql.FieldGTE(FieldBanReason, v))
}
// BanReasonLT applies the LT predicate on the "ban_reason" field.
func BanReasonLT(v string) predicate.User {
return predicate.User(sql.FieldLT(FieldBanReason, v))
}
// BanReasonLTE applies the LTE predicate on the "ban_reason" field.
func BanReasonLTE(v string) predicate.User {
return predicate.User(sql.FieldLTE(FieldBanReason, v))
}
// BanReasonContains applies the Contains predicate on the "ban_reason" field.
func BanReasonContains(v string) predicate.User {
return predicate.User(sql.FieldContains(FieldBanReason, v))
}
// BanReasonHasPrefix applies the HasPrefix predicate on the "ban_reason" field.
func BanReasonHasPrefix(v string) predicate.User {
return predicate.User(sql.FieldHasPrefix(FieldBanReason, v))
}
// BanReasonHasSuffix applies the HasSuffix predicate on the "ban_reason" field.
func BanReasonHasSuffix(v string) predicate.User {
return predicate.User(sql.FieldHasSuffix(FieldBanReason, v))
}
// BanReasonIsNil applies the IsNil predicate on the "ban_reason" field.
func BanReasonIsNil() predicate.User {
return predicate.User(sql.FieldIsNull(FieldBanReason))
}
// BanReasonNotNil applies the NotNil predicate on the "ban_reason" field.
func BanReasonNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldBanReason))
}
// BanReasonEqualFold applies the EqualFold predicate on the "ban_reason" field.
func BanReasonEqualFold(v string) predicate.User {
return predicate.User(sql.FieldEqualFold(FieldBanReason, v))
}
// BanReasonContainsFold applies the ContainsFold predicate on the "ban_reason" field.
func BanReasonContainsFold(v string) predicate.User {
return predicate.User(sql.FieldContainsFold(FieldBanReason, v))
}
// LastLoginEQ applies the EQ predicate on the "last_login" field.
func LastLoginEQ(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldLastLogin, v))
}
// LastLoginNEQ applies the NEQ predicate on the "last_login" field.
func LastLoginNEQ(v time.Time) predicate.User {
return predicate.User(sql.FieldNEQ(FieldLastLogin, v))
}
// LastLoginIn applies the In predicate on the "last_login" field.
func LastLoginIn(vs ...time.Time) predicate.User {
return predicate.User(sql.FieldIn(FieldLastLogin, vs...))
}
// LastLoginNotIn applies the NotIn predicate on the "last_login" field.
func LastLoginNotIn(vs ...time.Time) predicate.User {
return predicate.User(sql.FieldNotIn(FieldLastLogin, vs...))
}
// LastLoginGT applies the GT predicate on the "last_login" field.
func LastLoginGT(v time.Time) predicate.User {
return predicate.User(sql.FieldGT(FieldLastLogin, v))
}
// LastLoginGTE applies the GTE predicate on the "last_login" field.
func LastLoginGTE(v time.Time) predicate.User {
return predicate.User(sql.FieldGTE(FieldLastLogin, v))
}
// LastLoginLT applies the LT predicate on the "last_login" field.
func LastLoginLT(v time.Time) predicate.User {
return predicate.User(sql.FieldLT(FieldLastLogin, v))
}
// LastLoginLTE applies the LTE predicate on the "last_login" field.
func LastLoginLTE(v time.Time) predicate.User {
return predicate.User(sql.FieldLTE(FieldLastLogin, v))
}
// LastLoginIsNil applies the IsNil predicate on the "last_login" field.
func LastLoginIsNil() predicate.User {
return predicate.User(sql.FieldIsNull(FieldLastLogin))
}
// LastLoginNotNil applies the NotNil predicate on the "last_login" field.
func LastLoginNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldLastLogin))
}
// StorageEQ applies the EQ predicate on the "storage" field.
func StorageEQ(v int64) predicate.User {
return predicate.User(sql.FieldEQ(FieldStorage, v))

@ -114,6 +114,48 @@ func (uc *UserCreate) SetNillableStatus(u *user.Status) *UserCreate {
return uc
}
// SetBanExpires sets the "ban_expires" field.
func (uc *UserCreate) SetBanExpires(t time.Time) *UserCreate {
uc.mutation.SetBanExpires(t)
return uc
}
// SetNillableBanExpires sets the "ban_expires" field if the given value is not nil.
func (uc *UserCreate) SetNillableBanExpires(t *time.Time) *UserCreate {
if t != nil {
uc.SetBanExpires(*t)
}
return uc
}
// SetBanReason sets the "ban_reason" field.
func (uc *UserCreate) SetBanReason(s string) *UserCreate {
uc.mutation.SetBanReason(s)
return uc
}
// SetNillableBanReason sets the "ban_reason" field if the given value is not nil.
func (uc *UserCreate) SetNillableBanReason(s *string) *UserCreate {
if s != nil {
uc.SetBanReason(*s)
}
return uc
}
// SetLastLogin sets the "last_login" field.
func (uc *UserCreate) SetLastLogin(t time.Time) *UserCreate {
uc.mutation.SetLastLogin(t)
return uc
}
// SetNillableLastLogin sets the "last_login" field if the given value is not nil.
func (uc *UserCreate) SetNillableLastLogin(t *time.Time) *UserCreate {
if t != nil {
uc.SetLastLogin(*t)
}
return uc
}
// SetStorage sets the "storage" field.
func (uc *UserCreate) SetStorage(i int64) *UserCreate {
uc.mutation.SetStorage(i)
@ -468,6 +510,18 @@ func (uc *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldStatus, field.TypeEnum, value)
_node.Status = value
}
if value, ok := uc.mutation.BanExpires(); ok {
_spec.SetField(user.FieldBanExpires, field.TypeTime, value)
_node.BanExpires = &value
}
if value, ok := uc.mutation.BanReason(); ok {
_spec.SetField(user.FieldBanReason, field.TypeString, value)
_node.BanReason = value
}
if value, ok := uc.mutation.LastLogin(); ok {
_spec.SetField(user.FieldLastLogin, field.TypeTime, value)
_node.LastLogin = &value
}
if value, ok := uc.mutation.Storage(); ok {
_spec.SetField(user.FieldStorage, field.TypeInt64, value)
_node.Storage = value
@ -765,6 +819,60 @@ func (u *UserUpsert) UpdateStatus() *UserUpsert {
return u
}
// SetBanExpires sets the "ban_expires" field.
func (u *UserUpsert) SetBanExpires(v time.Time) *UserUpsert {
u.Set(user.FieldBanExpires, v)
return u
}
// UpdateBanExpires sets the "ban_expires" field to the value that was provided on create.
func (u *UserUpsert) UpdateBanExpires() *UserUpsert {
u.SetExcluded(user.FieldBanExpires)
return u
}
// ClearBanExpires clears the value of the "ban_expires" field.
func (u *UserUpsert) ClearBanExpires() *UserUpsert {
u.SetNull(user.FieldBanExpires)
return u
}
// SetBanReason sets the "ban_reason" field.
func (u *UserUpsert) SetBanReason(v string) *UserUpsert {
u.Set(user.FieldBanReason, v)
return u
}
// UpdateBanReason sets the "ban_reason" field to the value that was provided on create.
func (u *UserUpsert) UpdateBanReason() *UserUpsert {
u.SetExcluded(user.FieldBanReason)
return u
}
// ClearBanReason clears the value of the "ban_reason" field.
func (u *UserUpsert) ClearBanReason() *UserUpsert {
u.SetNull(user.FieldBanReason)
return u
}
// SetLastLogin sets the "last_login" field.
func (u *UserUpsert) SetLastLogin(v time.Time) *UserUpsert {
u.Set(user.FieldLastLogin, v)
return u
}
// UpdateLastLogin sets the "last_login" field to the value that was provided on create.
func (u *UserUpsert) UpdateLastLogin() *UserUpsert {
u.SetExcluded(user.FieldLastLogin)
return u
}
// ClearLastLogin clears the value of the "last_login" field.
func (u *UserUpsert) ClearLastLogin() *UserUpsert {
u.SetNull(user.FieldLastLogin)
return u
}
// SetStorage sets the "storage" field.
func (u *UserUpsert) SetStorage(v int64) *UserUpsert {
u.Set(user.FieldStorage, v)
@ -992,6 +1100,69 @@ func (u *UserUpsertOne) UpdateStatus() *UserUpsertOne {
})
}
// SetBanExpires sets the "ban_expires" field.
func (u *UserUpsertOne) SetBanExpires(v time.Time) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetBanExpires(v)
})
}
// UpdateBanExpires sets the "ban_expires" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateBanExpires() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateBanExpires()
})
}
// ClearBanExpires clears the value of the "ban_expires" field.
func (u *UserUpsertOne) ClearBanExpires() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.ClearBanExpires()
})
}
// SetBanReason sets the "ban_reason" field.
func (u *UserUpsertOne) SetBanReason(v string) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetBanReason(v)
})
}
// UpdateBanReason sets the "ban_reason" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateBanReason() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateBanReason()
})
}
// ClearBanReason clears the value of the "ban_reason" field.
func (u *UserUpsertOne) ClearBanReason() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.ClearBanReason()
})
}
// SetLastLogin sets the "last_login" field.
func (u *UserUpsertOne) SetLastLogin(v time.Time) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetLastLogin(v)
})
}
// UpdateLastLogin sets the "last_login" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateLastLogin() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateLastLogin()
})
}
// ClearLastLogin clears the value of the "last_login" field.
func (u *UserUpsertOne) ClearLastLogin() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.ClearLastLogin()
})
}
// SetStorage sets the "storage" field.
func (u *UserUpsertOne) SetStorage(v int64) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
@ -1404,6 +1575,69 @@ func (u *UserUpsertBulk) UpdateStatus() *UserUpsertBulk {
})
}
// SetBanExpires sets the "ban_expires" field.
func (u *UserUpsertBulk) SetBanExpires(v time.Time) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetBanExpires(v)
})
}
// UpdateBanExpires sets the "ban_expires" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateBanExpires() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateBanExpires()
})
}
// ClearBanExpires clears the value of the "ban_expires" field.
func (u *UserUpsertBulk) ClearBanExpires() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.ClearBanExpires()
})
}
// SetBanReason sets the "ban_reason" field.
func (u *UserUpsertBulk) SetBanReason(v string) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetBanReason(v)
})
}
// UpdateBanReason sets the "ban_reason" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateBanReason() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateBanReason()
})
}
// ClearBanReason clears the value of the "ban_reason" field.
func (u *UserUpsertBulk) ClearBanReason() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.ClearBanReason()
})
}
// SetLastLogin sets the "last_login" field.
func (u *UserUpsertBulk) SetLastLogin(v time.Time) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetLastLogin(v)
})
}
// UpdateLastLogin sets the "last_login" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateLastLogin() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateLastLogin()
})
}
// ClearLastLogin clears the value of the "last_login" field.
func (u *UserUpsertBulk) ClearLastLogin() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.ClearLastLogin()
})
}
// SetStorage sets the "storage" field.
func (u *UserUpsertBulk) SetStorage(v int64) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {

@ -126,6 +126,66 @@ func (uu *UserUpdate) SetNillableStatus(u *user.Status) *UserUpdate {
return uu
}
// SetBanExpires sets the "ban_expires" field.
func (uu *UserUpdate) SetBanExpires(t time.Time) *UserUpdate {
uu.mutation.SetBanExpires(t)
return uu
}
// SetNillableBanExpires sets the "ban_expires" field if the given value is not nil.
func (uu *UserUpdate) SetNillableBanExpires(t *time.Time) *UserUpdate {
if t != nil {
uu.SetBanExpires(*t)
}
return uu
}
// ClearBanExpires clears the value of the "ban_expires" field.
func (uu *UserUpdate) ClearBanExpires() *UserUpdate {
uu.mutation.ClearBanExpires()
return uu
}
// SetBanReason sets the "ban_reason" field.
func (uu *UserUpdate) SetBanReason(s string) *UserUpdate {
uu.mutation.SetBanReason(s)
return uu
}
// SetNillableBanReason sets the "ban_reason" field if the given value is not nil.
func (uu *UserUpdate) SetNillableBanReason(s *string) *UserUpdate {
if s != nil {
uu.SetBanReason(*s)
}
return uu
}
// ClearBanReason clears the value of the "ban_reason" field.
func (uu *UserUpdate) ClearBanReason() *UserUpdate {
uu.mutation.ClearBanReason()
return uu
}
// SetLastLogin sets the "last_login" field.
func (uu *UserUpdate) SetLastLogin(t time.Time) *UserUpdate {
uu.mutation.SetLastLogin(t)
return uu
}
// SetNillableLastLogin sets the "last_login" field if the given value is not nil.
func (uu *UserUpdate) SetNillableLastLogin(t *time.Time) *UserUpdate {
if t != nil {
uu.SetLastLogin(*t)
}
return uu
}
// ClearLastLogin clears the value of the "last_login" field.
func (uu *UserUpdate) ClearLastLogin() *UserUpdate {
uu.mutation.ClearLastLogin()
return uu
}
// SetStorage sets the "storage" field.
func (uu *UserUpdate) SetStorage(i int64) *UserUpdate {
uu.mutation.ResetStorage()
@ -624,6 +684,24 @@ func (uu *UserUpdate) sqlSave(ctx context.Context) (n int, err error) {
if value, ok := uu.mutation.Status(); ok {
_spec.SetField(user.FieldStatus, field.TypeEnum, value)
}
if value, ok := uu.mutation.BanExpires(); ok {
_spec.SetField(user.FieldBanExpires, field.TypeTime, value)
}
if uu.mutation.BanExpiresCleared() {
_spec.ClearField(user.FieldBanExpires, field.TypeTime)
}
if value, ok := uu.mutation.BanReason(); ok {
_spec.SetField(user.FieldBanReason, field.TypeString, value)
}
if uu.mutation.BanReasonCleared() {
_spec.ClearField(user.FieldBanReason, field.TypeString)
}
if value, ok := uu.mutation.LastLogin(); ok {
_spec.SetField(user.FieldLastLogin, field.TypeTime, value)
}
if uu.mutation.LastLoginCleared() {
_spec.ClearField(user.FieldLastLogin, field.TypeTime)
}
if value, ok := uu.mutation.Storage(); ok {
_spec.SetField(user.FieldStorage, field.TypeInt64, value)
}
@ -1145,6 +1223,66 @@ func (uuo *UserUpdateOne) SetNillableStatus(u *user.Status) *UserUpdateOne {
return uuo
}
// SetBanExpires sets the "ban_expires" field.
func (uuo *UserUpdateOne) SetBanExpires(t time.Time) *UserUpdateOne {
uuo.mutation.SetBanExpires(t)
return uuo
}
// SetNillableBanExpires sets the "ban_expires" field if the given value is not nil.
func (uuo *UserUpdateOne) SetNillableBanExpires(t *time.Time) *UserUpdateOne {
if t != nil {
uuo.SetBanExpires(*t)
}
return uuo
}
// ClearBanExpires clears the value of the "ban_expires" field.
func (uuo *UserUpdateOne) ClearBanExpires() *UserUpdateOne {
uuo.mutation.ClearBanExpires()
return uuo
}
// SetBanReason sets the "ban_reason" field.
func (uuo *UserUpdateOne) SetBanReason(s string) *UserUpdateOne {
uuo.mutation.SetBanReason(s)
return uuo
}
// SetNillableBanReason sets the "ban_reason" field if the given value is not nil.
func (uuo *UserUpdateOne) SetNillableBanReason(s *string) *UserUpdateOne {
if s != nil {
uuo.SetBanReason(*s)
}
return uuo
}
// ClearBanReason clears the value of the "ban_reason" field.
func (uuo *UserUpdateOne) ClearBanReason() *UserUpdateOne {
uuo.mutation.ClearBanReason()
return uuo
}
// SetLastLogin sets the "last_login" field.
func (uuo *UserUpdateOne) SetLastLogin(t time.Time) *UserUpdateOne {
uuo.mutation.SetLastLogin(t)
return uuo
}
// SetNillableLastLogin sets the "last_login" field if the given value is not nil.
func (uuo *UserUpdateOne) SetNillableLastLogin(t *time.Time) *UserUpdateOne {
if t != nil {
uuo.SetLastLogin(*t)
}
return uuo
}
// ClearLastLogin clears the value of the "last_login" field.
func (uuo *UserUpdateOne) ClearLastLogin() *UserUpdateOne {
uuo.mutation.ClearLastLogin()
return uuo
}
// SetStorage sets the "storage" field.
func (uuo *UserUpdateOne) SetStorage(i int64) *UserUpdateOne {
uuo.mutation.ResetStorage()
@ -1673,6 +1811,24 @@ func (uuo *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error)
if value, ok := uuo.mutation.Status(); ok {
_spec.SetField(user.FieldStatus, field.TypeEnum, value)
}
if value, ok := uuo.mutation.BanExpires(); ok {
_spec.SetField(user.FieldBanExpires, field.TypeTime, value)
}
if uuo.mutation.BanExpiresCleared() {
_spec.ClearField(user.FieldBanExpires, field.TypeTime)
}
if value, ok := uuo.mutation.BanReason(); ok {
_spec.SetField(user.FieldBanReason, field.TypeString, value)
}
if uuo.mutation.BanReasonCleared() {
_spec.ClearField(user.FieldBanReason, field.TypeString)
}
if value, ok := uuo.mutation.LastLogin(); ok {
_spec.SetField(user.FieldLastLogin, field.TypeTime, value)
}
if uuo.mutation.LastLoginCleared() {
_spec.ClearField(user.FieldLastLogin, field.TypeTime)
}
if value, ok := uuo.mutation.Storage(); ok {
_spec.SetField(user.FieldStorage, field.TypeInt64, value)
}

@ -82,6 +82,7 @@
"redux": "^5.0.0",
"streamsaver": "^2.0.6",
"styled-components": "^6.1.11",
"three": "^0.186.0",
"timeago-react": "^3.0.6"
},
"devDependencies": {
@ -90,6 +91,7 @@
"@types/node": "^20.10.5",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/three": "^0.186.0",
"@types/webappsec-credential-management": "^0.6.9",
"@types/wicg-file-system-access": "^2023.10.5",
"@typescript-eslint/eslint-plugin": "^6.14.0",

@ -42,6 +42,8 @@
"signUpSuccess": "Sign up successful",
"activateSuccess": "Sign up completed",
"accountActivated": "Your account has been successfully activated.",
"emailChangedTitle": "Email changed",
"emailChanged": "Your email address has been changed successfully.",
"title": "Sign in to {{title}}",
"sinUpTitle": "Sign up to {{title}}",
"activateTitle": "Activate your account",
@ -230,10 +232,13 @@
"color": "Color",
"fontSize": "Font size",
"disableSubtitle": "Disable subtitle",
"loop": "Loop",
"noSubtitle": "No ASS/SRT/VTT subtitle files found under current folder.",
"subtitleStyles": "Subtitle styles",
"subtitles": "Subtitles",
"markdownEditor": "Markdown Editor",
"modelViewer": "3D Model Viewer",
"modelLoadFailed": "Failed to load 3D model",
"saveSuccess": "Saved successfully at {{time}}",
"drawioLng": "en",
"charset": "Charset",
@ -431,6 +436,9 @@
"serverBatchDownload": "Server-side archiving",
"serverBatchDownloadDescription": "Archive by the server to a Zip file and sent to the client for download on-the-fly, share link shortcut is not supported.",
"selectArchiveMethod": "Select archive method",
"selectDownloadRoute": "Select download route",
"downloadRouteDirect": "Direct",
"downloadRouteDirectDescription": "Download directly from this site",
"batchDownloadStarted": "Batch download has started, please do not close this tab...",
"downloadProgress": "{{completed}} / {{total}} files downloaded ({{percent}}%)",
"downloadComplete": "Download complete!",
@ -468,7 +476,9 @@
"noEncryption": "Not encrypted",
"ftsStatus": "Full-text index",
"ftsIndexed": "Indexed",
"ftsNotIndexed": "Not indexed"
"ftsNotIndexed": "Not indexed",
"lineEnding": "Line ending",
"downloadAll": "Download all"
},
"modals": {
"includePasswordInShareLink": "Include password in share link",
@ -486,6 +496,10 @@
"saveAs": "Save as",
"versionConflict": "Version conflict",
"discardUnsavedConfirm": "You have unsaved changes. Still discard?",
"nameConflict": "Name conflict",
"conflictOverwriteDes": "Replace the existing items in the destination.",
"conflictSkipDes": "Keep the existing items; conflicting items will not be moved or copied.",
"skip": "Skip",
"overwrite": "Overwrite",
"editShareLink": "Edit share link",
"clearPermissions": "Clear permission settings",
@ -554,6 +568,14 @@
"remoteDownloadURL": "Download target URL",
"remoteDownloadURLDescription": "Paste the download URL, one URL per line",
"remoteDownloadDst": "Download to",
"remoteDownloadFileName": "File name (optional)",
"remoteDownloadFileNameDescription": "Custom output file name",
"remoteDownloadHttpUser": "HTTP username (optional)",
"remoteDownloadHttpPassword": "HTTP password (optional)",
"remoteDownloadHeaders": "Request headers (optional)",
"remoteDownloadProvider": "Downloader",
"remoteDownloadProviderAuto": "Auto (any node)",
"remoteDownloadHeadersDescription": "One header per line, e.g. Cookie: sid=...",
"processNode": "Target node",
"remoteDownloadNodeAuto": "Auto dispatch",
"createTask": "Create task",
@ -629,6 +651,8 @@
"uploadOnlyDes": "Visitors can upload files but cannot see or download existing content in this shared folder.",
"previewOnly": "Preview only",
"previewOnlyDes": "Visitors can preview files inline but cannot download them.",
"shareNote": "Note",
"shareNoteDes": "A private note to identify this share. Only visible to you in My Shares.",
"showReadme": "Show README file",
"showReadmeDes": "If selected, the <0>README.md</0> file (case-sensitive) in the directory will be automatically displayed for visitors.",
"viewSetting": "View setting",
@ -657,7 +681,7 @@
"preparing": "Preparing...",
"processing": "Processing...",
"progressDescription": "{{uploaded}} uploaded, {{total}} total - {{percentage}}%",
"progressDescriptionFull": "{{uploaded}} uploaded, {{total}} total - {{percentage}}% ({{speed}})",
"progressDescriptionFull": "{{uploaded}} uploaded, {{total}} total - {{percentage}}% ({{speed}}){{eta}}",
"progressDescriptionPlaceHolder": " - uploaded",
"uploaded": "Uploaded",
"rootFolder": "Root folder",
@ -716,7 +740,8 @@
"upyunUploadFailed": "Upload failed: {{msg}}",
"parseResponseError": "Unable to parse response: {{msg}} ({{content}})",
"concurrentTaskNumber": "Concurrent task limit",
"dropFileHere": "Drop file to upload"
"dropFileHere": "Drop file to upload",
"etaSuffix": ", ETA {{eta}}"
},
"share": {
"statistics": "Statistics",
@ -815,6 +840,9 @@
"syncViewOff": "Do not sync",
"folderClickAction": "Folder click action",
"folderClickActionDes": "Choose what happens when you click a folder. \"Open\" navigates into the folder, \"Select\" selects it (double-click to open).",
"preferredViewers": "Default apps",
"preferredViewersDes": "Apps chosen with \"Always\" in the open-with dialog. They apply on all your devices; remove one to be asked again.",
"preferredViewersEmpty": "No default apps saved yet. Pick \"Always\" in the open-with dialog on a file.",
"folderClickOpen": "Open",
"folderClickSelect": "Select",
"noAuthenticator": "Add a passkey to sign in using fingerprint, face or USB key.",
@ -827,6 +855,8 @@
"enableVersionRetention": "Enable version retention",
"enableVersionRetentionDes": "If enabled, historical versions of files that meet the conditions will be retained.",
"versionRetention": "Version retention",
"trashRetention": "Trash retention (days)",
"trashRetentionDes": "Days a file stays in the trash before automatic deletion. 0 inherits the group default. Applies to files moved to trash after the change.",
"languageDes": "Select the display language and preferred email language.",
"timezoneDes": "Set the display timezone, default is system timezone",
"externalApps": "External Apps",
@ -834,6 +864,11 @@
"revokeOAuthGrant": "Revoke Authorization",
"revokeOAuthGrantConfirm": "Are you sure you want to revoke this app's authorization? Once revoked, this app will no longer be able to access your account.",
"nickNameDes": "This is your public display name. It can be your real name or a pseudonym.",
"changeEmail": "Change",
"changeEmailDes": "A confirmation link will be sent to the new address. Your current email ({{email}}) stays active until confirmed.",
"newEmail": "New email address",
"changeEmailPasswordDes": "Enter your current password to confirm.",
"emailChangeSent": "Confirmation email sent to the new address.",
"cropAvatar": "Crop avatar",
"preference": "Preference",
"accountCreatedAt": "Created at <0></0>",
@ -940,6 +975,8 @@
"disable2FA": "Disable 2FA authentication",
"2faDescription": "Please use any 2FA mobile app or password management software that supports 2FA to scan the QR code to add this site. After scanning, please fill in the 6-digit verification code given by the 2FA app to enable 2FA.",
"inputCurrent2FACode": "Enter current 2FA verification code.",
"copySecret": "Can't scan? Click to copy the setup key",
"secretCopied": "Setup key copied",
"timeZoneCode": "IANA timezone code",
"authenticatorRemoved": "Authenticator removed.",
"authenticatorAdded": "Authenticator added.",
@ -1003,7 +1040,13 @@
"relocateFileTo": "Relocate storage policy to {{policy}} for <0></0>{{more}}",
"extractFileTo": "Extract <0></0>{{more}} to <1></1>",
"createArchiveTo": "Create archive file to <1></1> for <0></0>{{more}}",
"importFileTo": "Import files from {{policy}} to <0></0>"
"importFileTo": "Import files from {{policy}} to <0></0>",
"shareSiteDefault": "Site default",
"shareSiteDefaultDes": "Follow the site-wide default configured by the administrator.",
"shareDefaultPrivate": "Default share privacy",
"shareDefaultPrivateDes": "New shares default to private (password protected).",
"sharePrivateOn": "Private",
"sharePrivateOff": "Public"
},
"vas": {
"points": "Points",

@ -54,7 +54,7 @@
"40014": "Exceed batch size limit of getting source link.",
"40015": "Exceed aria2 batch size limit.",
"40016": "Path not found.",
"40017": "This account has been blocked.",
"40017": "This account has been blocked. {{message}}",
"40018": "This account is not activated.",
"40019": "This feature is not enabled.",
"40020": "Invalid or expired credential.",
@ -102,5 +102,6 @@
"50005": "Internal error.",
"50010": "Desired node is unavailable.",
"50011": "Failed to query file metadata."
}
},
"delete": "Delete"
}

@ -170,6 +170,9 @@
"customNavItems": "Custom sidebar items",
"customNavItemsDes": "You can add custom items to the sidebar, and users will be redirected to the corresponding link when clicked.",
"navItemUrl": "Link",
"navItemVisibility": "Visibility",
"navScopePublic": "Everyone",
"navScopeUser": "Logged-in users",
"iconifyNamePlaceholder": "Iconify icon identifier, e.g. fluent:home-24-regular",
"imageUrl": "Image URL",
"iconifyName": "Iconify icon name",
@ -837,8 +840,20 @@
"ssoIssuerDes": "Base URL of the OIDC issuer, e.g. <0>https://keycloak.example.com/realms/master</0>. The provider metadata is fetched from <1>.well-known/openid-configuration</1> under this URL.",
"ssoRegisterEnabled": "Allow automatic registration",
"ssoRegisterEnabledDes": "Automatically create a local account when a user signs in via SSO for the first time. The sign-up email filter below also applies.",
"downloadCdnRoutes": "Download CDN routes",
"downloadCdnRoutesDes": "Alternative download endpoints offered to users, one per line in name=url format (e.g. Line 1=https://cdn1.example.com). Routes must proxy the full request path and query back to this site so signed URLs stay valid; CORS headers are required for in-browser downloads.",
"ssoAutoRedirect": "Auto redirect to SSO",
"ssoAutoRedirectDes": "Skip the login form and send visitors straight to the identity provider. Append <0>?nosso=1</0> to the login URL to reach the password form (e.g. for admin recovery).",
"ssoCallbackUrl": "Callback URL",
"ssoCallbackUrlDes": "Register this URL as the redirect/callback URL in your identity provider: <0>{{url}}</0>"
"ssoCallbackUrlDes": "Register this URL as the redirect/callback URL in your identity provider: <0>{{url}}</0>",
"themeVisible": "Visible",
"shareDefaultPrivate": "Private share by default",
"shareDefaultPrivateDes": "New shares default to private (password protected). Users can override this in their personal settings.",
"defaultShareLinksInProfile": "Default profile share visibility",
"defaultShareLinksInProfileDes": "Controls which shares appear on a user's public profile page when the user has not chosen one themselves.",
"profileSharePublicOnly": "Shares without password only",
"profileShareAll": "All shares",
"profileShareHide": "Hide all shares"
},
"giftCodes": {
"giftCodesSettings": "Gift Codes",
@ -977,6 +992,12 @@
"addXStoragePolicy": "Add {{type}} storage policy",
"loadSummary": "Load summary",
"policySummary": "{{count}} file Blobs ({{size}})",
"blobAudit": "Audit blobs: {{name}}",
"blobAuditDes": "Scans the storage backend and lists physical objects not referenced by any file record. Useful for finding ghost blobs left by failed transfers.",
"blobAuditDelete": "Delete orphaned blobs",
"blobAuditDeleteWarning": "Orphaned objects will be permanently removed from the storage backend. Files still being uploaded may appear as orphans — only enable this when you are sure no uploads are in progress.",
"blobAuditStart": "Start audit",
"blobAuditSubmitted": "Blob audit task queued.",
"sharp": "#",
"name": "Name",
"type": "Type",
@ -1019,6 +1040,8 @@
"blacklist": "Deny",
"fileNameRegex": "File name regex rules",
"fileNameRegexDes": "Regular expression to match file names, leave blank for no restriction.",
"allowNativeName": "Allow native file name characters",
"allowNativeNameDes": "Permit characters only illegal on Windows filesystems (: * ? \" < > |) in file names. May break clients mounting on Windows. Path separators remain forbidden.",
"chunkSizeDes": "Specify the chunk size for chunked uploads. A value of 0 means no chunked uploads are used, but the maximum upload size may be limited by the web server.",
"chunkSizeDesSuffix": "{{prefix}} With chunked upload, the files uploaded by users will be sliced into chunks and uploaded to the storage side one by one. After the upload is interrupted, users can choose to continue uploading from the last uploaded chunk.",
"chunkSize": "Chunk size",
@ -1163,7 +1186,9 @@
"fileEncryption": "File encryption",
"enableFileEncryption": "Enable file encryption",
"enableFileEncryptionDes": "When enabled, file blobs will be stored encrypted. Changing this setting only affects newly added files.",
"encryptedFile": "Encrypted files"
"encryptedFile": "Encrypted files",
"status": "Status",
"statusDes": "Suspended policies keep serving existing files but reject new uploads."
},
"node": {
"slave": "slave",
@ -1254,6 +1279,20 @@
"initialStorageQuotaDes": "Max storage can used by single user under this group.",
"isAdmin": "Admin group",
"isAdminDes": "When enabled, users under this group will have admin permissions.",
"loginIPWhitelist": "Login IP whitelist",
"loginIPWhitelistDes": "Restrict sign-in to these IPs or CIDR ranges, one per line. Leave empty to allow all.",
"delegatedAdmin": "Delegated admin sections",
"delegatedAdminDes": "Grant access to selected dashboard sections only. Users in this group can open the admin panel, but only the enabled sections work — all other admin API calls are rejected.",
"sectionUsers": "Users",
"sectionGroups": "Groups",
"sectionFiles": "Files & blobs",
"sectionShares": "Shares",
"sectionStorage": "Storage policies & nodes",
"sectionQueue": "Task queue",
"sectionSettings": "Site settings & OAuth",
"sectionPayment": "Payments",
"sectionEvents": "Events",
"sectionReports": "Abuse reports",
"share": "Share",
"allowCreateShareLink": "Create share link",
"allowCreateShareLinkDes": "If disabled, users cannot create sharing links.",
@ -1264,6 +1303,8 @@
"allowWabDAVDes": "If disabled, users cannot connect to the storage via the WebDAV protocol",
"allowWabDAVProxy": "WebDAV Proxy",
"allowWabDAVProxyDes": "If enabled, users can configure the WebDAV to be proxied by Cloudreve when downloading files.",
"wabDAVReadOnly": "WebDAV read-only",
"wabDAVReadOnlyDes": "If enabled, WebDAV mounts for users in this group become read-only — uploads, deletes, moves and other write methods are rejected.",
"compressTask": "Compression/Decompression tasks",
"compressTaskDes": "If enabled, users can do compression/decompression for files online.",
"compressSize": "Maximum file size to be compressed",
@ -1369,7 +1410,17 @@
"deleteXUsers": "Delete {{num}} users",
"confirmBatchDelete": "Are you sure you want to delete {{num}} users?",
"calibrateStorage": "Calibrate storage",
"calibrateStorageSuccess": "Storage calibrated successfully."
"calibrateStorageSuccess": "Storage calibrated successfully.",
"banExpires": "Ban expires",
"banExpiresDes": "Optional. The ban lifts automatically after this time; empty means permanent.",
"banReason": "Ban reason",
"banReasonDes": "Optional. Shown to the user when a banned login is rejected.",
"lastLogin": "Last login",
"uids": "User IDs",
"uidsPlaceholder": "Comma-separated IDs, e.g. 2,3,5",
"editXUsers": "Edit {{num}} users",
"batchEditXUsers": "Batch edit {{num}} users",
"noChange": "No change"
},
"file": {
"deleteXFiles": "Delete {{num}} files",
@ -1385,6 +1436,11 @@
"shareLink": "Share links",
"shareLinkNum": "{{num}} (<0>View</0>)",
"blobType": "Type",
"referenceCount": "Reference count",
"awaitingRecycle": "Awaiting recycle",
"refCountGt": "Greater than",
"refCountLt": "Less than",
"refCountEq": "Equal to",
"noEntities": "No Blobs",
"blobs": "Blobs",
"creator": "Creator",
@ -1440,7 +1496,10 @@
"otherConditions": "Other conditions",
"shareLinkExisted": "Has share link",
"directLinkExisted": "Has direct link",
"isUploading": "Is uploading"
"isUploading": "Is uploading",
"deletedState": "Deleted state",
"liveOnly": "Live files only",
"trashOnly": "Trash only"
},
"entity": {
"refenenceCount": "Reference count",
@ -1554,6 +1613,7 @@
"fullTextCopy": "Copy full-text index for file <0>#{{fileID}}</0>",
"fullTextChangeOwner": "Change index owner for file <0>#{{fileID}}</0>",
"fullTextDelete": "Delete full-text index for {{count}} files",
"blobAudit": "Audit blobs on storage policy #{{policyID}} ({{count}} orphans)",
"type": "Type",
"node": "Distributed node",
"createdBy": "Created by",
@ -1744,7 +1804,9 @@
"customPaymentEndpointDes": "URL to be requested when creating a payment order.",
"appFeedback": "Feedback URL",
"appForum": "User forum URL",
"appLinkDes": "Will be displayed in mobile client, leave empty to hide menu item. This setting will take effect only if VOL license is valid."
"appLinkDes": "Will be displayed in mobile client, leave empty to hide menu item. This setting will take effect only if VOL license is valid.",
"subAddressChars": "Sub-address characters",
"subAddressCharsDes": "Characters treated as sub-address separators in the local part of the email (e.g. <code>+-.</code> for Gmail/iCloud-style aliases). Empty defaults to <code>+</code>."
},
"pro": {
"title": "Pro edition exclusive features",
@ -1776,4 +1838,4 @@
"shareID": "Shared ID",
"reason": "Reason"
}
}
}

@ -42,6 +42,8 @@
"signUpSuccess": "注册成功",
"activateSuccess": "激活成功",
"accountActivated": "您的账号已被成功激活",
"emailChangedTitle": "邮箱已更换",
"emailChanged": "您的邮箱地址已成功更换。",
"title": "登录 {{title}}",
"sinUpTitle": "注册 {{title}}",
"activateTitle": "邮件激活",
@ -230,10 +232,13 @@
"color": "颜色",
"fontSize": "字体大小",
"disableSubtitle": "禁用字幕",
"loop": "循环播放",
"noSubtitle": "没有在当前目录下找到 ASS/SRT/VTT 字幕文件。",
"subtitleStyles": "字幕样式",
"subtitles": "字幕",
"markdownEditor": "Markdown 编辑器",
"modelViewer": "3D 模型查看器",
"modelLoadFailed": "3D 模型加载失败",
"saveSuccess": "在 {{time}} 保存成功",
"drawioLng": "zh",
"charset": "编码",
@ -431,6 +436,9 @@
"serverBatchDownload": "服务端中转打包",
"serverBatchDownloadDescription": "由服务端中转打包为 Zip 文件并实时发送到客户端下载,不支持分享快捷方式。",
"selectArchiveMethod": "选择批量下载方式",
"selectDownloadRoute": "选择下载线路",
"downloadRouteDirect": "直连",
"downloadRouteDirectDescription": "直接从本站下载",
"batchDownloadStarted": "打包下载已开始,请不要关闭此页面...",
"downloadProgress": "已下载 {{completed}} / {{total}} 个文件 ({{percent}}%)",
"downloadComplete": "下载完成!",
@ -468,7 +476,9 @@
"noEncryption": "未加密",
"ftsStatus": "全文索引",
"ftsIndexed": "已索引",
"ftsNotIndexed": "未索引"
"ftsNotIndexed": "未索引",
"lineEnding": "换行符",
"downloadAll": "全部下载"
},
"modals": {
"includePasswordInShareLink": "在链接中包含密码",
@ -486,6 +496,10 @@
"saveAs": "另存为",
"versionConflict": "版本冲突",
"discardUnsavedConfirm": "有未保存的更改,确定要关闭吗?",
"nameConflict": "名称冲突",
"conflictOverwriteDes": "替换目标位置中的同名项目。",
"conflictSkipDes": "保留目标位置的同名项目,冲突的项目不会被移动或复制。",
"skip": "跳过",
"overwrite": "覆盖",
"editShareLink": "编辑分享链接",
"clearPermissions": "清除权限设置",
@ -554,6 +568,14 @@
"remoteDownloadURL": "下载链接",
"remoteDownloadURLDescription": "输入文件下载地址,一行一个",
"remoteDownloadDst": "下载至",
"remoteDownloadFileName": "文件名(可选)",
"remoteDownloadFileNameDescription": "自定义保存文件名",
"remoteDownloadHttpUser": "HTTP 用户名(可选)",
"remoteDownloadHttpPassword": "HTTP 密码(可选)",
"remoteDownloadHeaders": "请求头(可选)",
"remoteDownloadProvider": "下载器",
"remoteDownloadProviderAuto": "自动(任意节点)",
"remoteDownloadHeadersDescription": "每行一个请求头,例如 Cookie: sid=...",
"processNode": "处理节点",
"remoteDownloadNodeAuto": "自动分配",
"createTask": "创建任务",
@ -629,6 +651,8 @@
"uploadOnlyDes": "访客可以上传文件,但无法查看或下载此共享文件夹中的现有内容。",
"previewOnly": "仅预览",
"previewOnlyDes": "访客可以在线预览文件,但不能下载。",
"shareNote": "备注",
"shareNoteDes": "用于识别此分享的私有备注,仅在“我的分享”中对你可见。",
"showReadme": "显示 README 文件",
"showReadmeDes": "勾选后,会自动为访问者展示目录下的 <0>README.md</0> (区分大小写) 文件。",
"viewSetting": "视图设置",
@ -657,7 +681,7 @@
"preparing": "准备中...",
"processing": "处理中...",
"progressDescription": "已上传 {{uploaded}},共 {{total}} - {{percentage}}%",
"progressDescriptionFull": "{{speed}} 已上传 {{uploaded}},共 {{total}} - {{percentage}}%",
"progressDescriptionFull": "{{speed}} 已上传 {{uploaded}},共 {{total}} - {{percentage}}%{{eta}}",
"progressDescriptionPlaceHolder": "已上传 - ",
"uploaded": "已上传",
"rootFolder": "根目录",
@ -716,7 +740,8 @@
"upyunUploadFailed": "上传失败:{{msg}}",
"parseResponseError": "无法解析响应:{{msg}} ({{content}})",
"concurrentTaskNumber": "同时上传的任务数量",
"dropFileHere": "松开鼠标开始上传"
"dropFileHere": "松开鼠标开始上传",
"etaSuffix": ",预计剩余 {{eta}}"
},
"share": {
"statistics": "统计",
@ -815,6 +840,9 @@
"syncViewOff": "不同步",
"folderClickAction": "文件夹点击操作",
"folderClickActionDes": "选择点击文件夹时的行为。「打开」将进入文件夹,「选中」将选中文件夹(双击打开)。",
"preferredViewers": "默认打开方式",
"preferredViewersDes": "在「打开方式」对话框中选择「始终」后保存的应用,会在所有设备上生效。删除后下次打开时将重新询问。",
"preferredViewersEmpty": "尚未保存默认应用。在文件的「打开方式」对话框中选择「始终」即可添加。",
"folderClickOpen": "打开",
"folderClickSelect": "选中",
"noAuthenticator": "添加通行密钥以使用人脸、指纹或 USB 密钥登录账号",
@ -827,6 +855,8 @@
"enableVersionRetention": "启用版本保留",
"enableVersionRetentionDes": "启用后,对于符合条件的文件,系统会保留其历史版本",
"versionRetention": "版本保留",
"trashRetention": "回收站保留时间(天)",
"trashRetentionDes": "文件移入回收站后自动删除前保留的天数。0 表示继承用户组默认值,仅影响修改后移入回收站的文件。",
"languageDes": "设置应用展示语言和首选邮件语言",
"timezoneDes": "设置展示时区,默认跟随系统时区",
"externalApps": "外部应用",
@ -834,6 +864,11 @@
"revokeOAuthGrant": "撤销授权",
"revokeOAuthGrantConfirm": "确定要撤销此应用的授权吗?撤销后,该应用将无法再访问你的账户。",
"nickNameDes": "用于公开展示的名字,可使用真实姓名或昵称",
"changeEmail": "更换",
"changeEmailDes": "确认链接将发送到新邮箱。在确认之前,当前邮箱({{email}})仍然有效。",
"newEmail": "新邮箱地址",
"changeEmailPasswordDes": "请输入当前密码以确认操作。",
"emailChangeSent": "确认邮件已发送至新邮箱。",
"cropAvatar": "裁剪头像",
"preference": "偏好",
"accountCreatedAt": "创建于 <0></0>",
@ -940,6 +975,8 @@
"disable2FA": "关闭二步验证",
"2faDescription": "请使用任意二步验证 APP 或者支持二步验证的密码管理软件扫描二维码添加本站。扫描完成后请填写二步验证 APP 给出的 6 位验证码以开启二步验证。",
"inputCurrent2FACode": "输入当前二步验证 APP 给出的 6 位验证码:",
"copySecret": "无法扫码?点击复制密钥",
"secretCopied": "已复制密钥",
"timeZoneCode": "IANA 时区名称标识",
"authenticatorRemoved": "凭证已删除",
"authenticatorAdded": "验证器已添加",
@ -1003,7 +1040,13 @@
"relocateFileTo": "将 <0></0>{{more}} 的存储策略转移至 {{policy}}",
"extractFileTo": "将 <0></0>{{more}} 解压缩至 <1></1>",
"createArchiveTo": "将 <0></0>{{more}} 打包至 <1></1>",
"importFileTo": "将 {{policy}} 中的文件导入至 <0></0>"
"importFileTo": "将 {{policy}} 中的文件导入至 <0></0>",
"shareSiteDefault": "跟随站点默认",
"shareSiteDefaultDes": "使用管理员配置的站点级默认值。",
"shareDefaultPrivate": "默认分享隐私",
"shareDefaultPrivateDes": "新建分享默认启用私密分享(密码保护)。",
"sharePrivateOn": "私密",
"sharePrivateOff": "公开"
},
"vas": {
"points": "积分",

@ -54,7 +54,7 @@
"40014": "超出批量获取外链限制",
"40015": "超出最大离线下载任务数量限制",
"40016": "路径不存在",
"40017": "该账号已被封禁",
"40017": "该账户已被封禁。{{message}}",
"40018": "该账号未激活",
"40019": "此功能未启用",
"40020": "凭证无效或过期",
@ -102,5 +102,7 @@
"50005": "内部错误 ({{message}})",
"50010": "目标节点不可用",
"50011": "文件元信息查询失败"
}
},
"delete": "删除",
"40017": "该账号已被封禁。{{message}}"
}

@ -170,6 +170,9 @@
"customNavItems": "自定义侧边导航栏",
"customNavItemsDes": "你可以在左侧导航栏中添加自定义的条目,用户点击后会跳转到对应的链接。",
"navItemUrl": "链接",
"navItemVisibility": "可见范围",
"navScopePublic": "所有人",
"navScopeUser": "登录用户",
"iconifyNamePlaceholder": "Iconify 图标标识,比如:fluent:home-24-regular",
"imageUrl": "图像 URL",
"iconifyName": "Iconify 图标名",
@ -837,8 +840,20 @@
"ssoIssuerDes": "OIDC Issuer 的基础 URL,例如 <0>https://keycloak.example.com/realms/master</0>。将从该地址下的 <1>.well-known/openid-configuration</1> 获取提供方元数据。",
"ssoRegisterEnabled": "允许自动注册",
"ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。",
"downloadCdnRoutes": "下载 CDN 线路",
"downloadCdnRoutesDes": "供用户选择的备用下载端点,每行一条,格式为 名称=URL(例如 线路1=https://cdn1.example.com)。线路需将完整的请求路径与查询串代理回本站以保证签名有效;浏览器内下载需要线路配置 CORS 头。",
"ssoAutoRedirect": "自动跳转至 SSO",
"ssoAutoRedirectDes": "跳过登录表单,直接跳转至身份提供商。在登录地址后附加 <0>?nosso=1</0> 可进入密码登录表单(例如管理员账户恢复)。",
"ssoCallbackUrl": "回调地址",
"ssoCallbackUrlDes": "请在身份提供方中将此 URL 注册为 Redirect/Callback URL:<0>{{url}}</0>"
"ssoCallbackUrlDes": "请在身份提供方中将此 URL 注册为 Redirect/Callback URL:<0>{{url}}</0>",
"themeVisible": "可见",
"shareDefaultPrivate": "默认私密分享",
"shareDefaultPrivateDes": "新建分享默认启用私密分享(密码保护)。用户仍可在个人设置中覆盖此默认值。",
"defaultShareLinksInProfile": "默认个人主页分享可见性",
"defaultShareLinksInProfileDes": "当用户未自行选择时,控制其公开个人主页上显示哪些分享。",
"profileSharePublicOnly": "仅无密码分享",
"profileShareAll": "所有分享",
"profileShareHide": "隐藏所有分享"
},
"giftCodes": {
"giftCodesSettings": "礼品码",
@ -977,6 +992,12 @@
"addXStoragePolicy": "添加 {{type}} 存储策略",
"loadSummary": "加载统计数据",
"policySummary": "{{count}} 个文件 Blob ({{size}})",
"blobAudit": "Blob 审计:{{name}}",
"blobAuditDes": "扫描存储后端,列出没有任何文件记录引用的物理对象,用于清理传输失败遗留的幽灵 Blob。",
"blobAuditDelete": "删除孤儿 Blob",
"blobAuditDeleteWarning": "孤儿对象将被从存储后端永久删除。正在上传中的文件可能会被误判为孤儿——请仅在确认没有上传任务进行时启用。",
"blobAuditStart": "开始审计",
"blobAuditSubmitted": "Blob 审计任务已加入队列。",
"sharp": "#",
"name": "名称",
"type": "类型",
@ -1019,6 +1040,8 @@
"blacklist": "拒绝",
"fileNameRegex": "文件名正则规则",
"fileNameRegexDes": "用于匹配文件名的正则表达式,留空表示无限制。",
"allowNativeName": "允许原生文件名字符",
"allowNativeNameDes": "允许文件名包含仅在 Windows 文件系统中非法的字符(: * ? \" < > |)。可能影响 Windows 客户端挂载使用。路径分隔符仍然禁止。",
"chunkSizeDes": "请指定分片上传时的分片大小,填写为 0 表示不使用分片上传,但最大上传大小可能受限于 Web 服务器。",
"chunkSizeDesSuffix": "{{prefix}}通过分片上传,用户上传的文件将会被切分成分片逐个上传到存储端,当上传中断后,用户可以选择从上次上传的分片后继续开始上传。",
"chunkSize": "上传分片大小",
@ -1163,7 +1186,9 @@
"fileEncryption": "文件加密",
"enableFileEncryption": "启用文件加密",
"enableFileEncryptionDes": "开启后,文件 Blob 会被加密存储。更改此设置只对新增的文件有效。",
"encryptedFile": "加密的文件"
"encryptedFile": "加密的文件",
"status": "状态",
"statusDes": "已暂停的存储策略仍可读取已有文件,但会拒绝新的上传。"
},
"node": {
"slave": "从机",
@ -1254,6 +1279,20 @@
"initialStorageQuotaDes": "用户组下的用户初始可用最大容量。",
"isAdmin": "管理员用户组",
"isAdminDes": "开启后,用户组下的用户将拥有管理员权限。",
"loginIPWhitelist": "登录 IP 白名单",
"loginIPWhitelistDes": "仅允许以下 IP 或 CIDR 网段登录,每行一条。留空则不限制。",
"delegatedAdmin": "委派管理区块",
"delegatedAdminDes": "仅授予所选管理后台区块的访问权限。该组用户可打开管理面板,但仅启用的区块可用,其余管理 API 请求会被拒绝。",
"sectionUsers": "用户",
"sectionGroups": "用户组",
"sectionFiles": "文件与存储块",
"sectionShares": "分享",
"sectionStorage": "存储策略与节点",
"sectionQueue": "任务队列",
"sectionSettings": "站点设置与 OAuth",
"sectionPayment": "支付",
"sectionEvents": "事件",
"sectionReports": "滥用举报",
"share": "分享",
"allowCreateShareLink": "创建分享链接",
"allowCreateShareLinkDes": "关闭后,用户无法创建分享链接。",
@ -1264,6 +1303,8 @@
"allowWabDAVDes": "关闭后,用户无法通过 WebDAV 协议连接至网盘。",
"allowWabDAVProxy": "WebDAV 代理",
"allowWabDAVProxyDes": "启用后,用户可以配置 WebDAV 下载经由 Cloudreve 中转。",
"wabDAVReadOnly": "WebDAV 只读",
"wabDAVReadOnlyDes": "启用后,该用户组的 WebDAV 挂载变为只读——上传、删除、移动及其他写操作将被拒绝。",
"compressTask": "压缩/解压缩任务",
"compressTaskDes": "开启后,用户可以在线压缩/解压缩文件。",
"compressSize": "待压缩文件最大大小",
@ -1369,7 +1410,17 @@
"deleteXUsers": "删除 {{num}} 个用户",
"confirmBatchDelete": "确认要删除 {{num}} 个用户?",
"calibrateStorage": "校准存储空间",
"calibrateStorageSuccess": "存储空间校准成功"
"calibrateStorageSuccess": "存储空间校准成功",
"banExpires": "封禁截止时间",
"banExpiresDes": "可选。到期后自动解除封禁;留空表示永久封禁。",
"banReason": "封禁原因",
"banReasonDes": "可选。封禁用户登录被拒绝时向其展示。",
"lastLogin": "上次登录",
"uids": "用户 ID",
"uidsPlaceholder": "逗号分隔的 ID,如 2,3,5",
"editXUsers": "编辑 {{num}} 个用户",
"batchEditXUsers": "批量编辑 {{num}} 个用户",
"noChange": "不修改"
},
"file": {
"deleteXFiles": "删除 {{num}} 个文件",
@ -1385,6 +1436,11 @@
"shareLink": "分享链接",
"shareLinkNum": "{{num}} 个 (<0>查看</0>)",
"blobType": "类型",
"referenceCount": "引用次数",
"awaitingRecycle": "等待回收",
"refCountGt": "大于",
"refCountLt": "小于",
"refCountEq": "等于",
"noEntities": "没有 Blob",
"blobs": "Blobs",
"creator": "创建者",
@ -1440,7 +1496,10 @@
"otherConditions": "其他条件",
"shareLinkExisted": "存在分享链接",
"directLinkExisted": "存在中转直链",
"isUploading": "上传中"
"isUploading": "上传中",
"deletedState": "删除状态",
"liveOnly": "仅未删除",
"trashOnly": "仅回收站"
},
"entity": {
"refenenceCount": "引用次数",
@ -1553,6 +1612,7 @@
"fullTextCopy": "复制文件 <0>#{{fileID}}</0> 的全文索引",
"fullTextChangeOwner": "变更文件 <0>#{{fileID}}</0> 的索引所有者",
"fullTextDelete": "删除 {{count}} 个文件的全文索引",
"blobAudit": "审计存储策略 #{{policyID}} 的 Blob({{count}} 个孤儿对象)",
"type": "类型",
"node": "处理节点",
"createdBy": "创建者",
@ -1743,7 +1803,9 @@
"customPaymentEndpointDes": "创建支付订单时请求的接口 URL。",
"appFeedback": "反馈页面 URL",
"appForum": "用户论坛 URL",
"appLinkDes": "用于在 App 设置页面展示,留空即不展示链接按钮,仅当 VOL 授权有效时此项设置才会生效。"
"appLinkDes": "用于在 App 设置页面展示,留空即不展示链接按钮,仅当 VOL 授权有效时此项设置才会生效。",
"subAddressChars": "子地址字符",
"subAddressCharsDes": "邮箱用户名部分中视为子地址分隔符的字符(如 <code>+-.</code>,覆盖 Gmail/iCloud 风格别名)。留空默认为 <code>+</code>。"
},
"pro": {
"title": "Pro 版本专属功能",
@ -1775,4 +1837,4 @@
"shareID": "分享 ID",
"reason": "原因"
}
}
}

@ -242,6 +242,7 @@ interface themeOptions {
interface singleThemeOption {
light: ThemeOptions;
dark?: ThemeOptions;
hidden?: boolean;
}
const getPreferredTheme = (
@ -250,16 +251,21 @@ const getPreferredTheme = (
preferredTheme?: string,
defaultTheme?: string,
): ThemeOptions => {
// Hidden themes are not user-selectable; fall back to the default when the
// preferred (or the last remaining) theme is hidden.
const visible = Object.values(opts).filter((o) => !o.hidden);
const pool = visible.length > 0 ? visible : Object.values(opts);
let themeConfig = {} as singleThemeOption;
if (defaultTheme && opts[defaultTheme]) {
if (defaultTheme && opts[defaultTheme] && !opts[defaultTheme].hidden) {
themeConfig = opts[defaultTheme];
}
if (preferredTheme && opts[preferredTheme]) {
if (preferredTheme && opts[preferredTheme] && !opts[preferredTheme].hidden) {
themeConfig = opts[preferredTheme];
}
if (!themeConfig?.light) {
themeConfig = Object.values(opts)[0];
themeConfig = pool[0];
}
if (mode === "dark" && themeConfig.dark) {

@ -106,6 +106,7 @@ import {
DownloadWorkflowService,
ImportWorkflowService,
ListTaskService,
BlobAuditWorkflowService,
RebuildFTSIndexWorkflowService,
SetDownloadFilesService,
TaskListResponse,
@ -468,12 +469,23 @@ export function sendMoveFile(req: MoveFileService): ThunkResponse<void> {
{
...defaultOpts,
skipBatchError: req.uris.length == 1,
// Leave conflict failures silent: the caller prompts for
// overwrite/skip first (#3159).
bypassSnackbar: isNameConflictBatchError,
},
),
);
};
}
export function isNameConflictBatchError(e: Error): boolean {
return (
e instanceof AppError &&
e.code == Code.BatchOperationNotFullyCompleted &&
Object.values(e.aggregatedError ?? {}).some((r) => r.code == Code.ObjectExist)
);
}
export function sendRestoreFile(req: DeleteFileService): ThunkResponse<void> {
return async (dispatch, _getState) => {
return await dispatch(
@ -576,6 +588,23 @@ export function sendDeleteShare(id: string): ThunkResponse<void> {
};
}
export function sendDeleteShares(ids: string[]): ThunkResponse<void> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/share",
{
method: "DELETE",
data: { ids },
},
{
...defaultOpts,
},
),
);
};
}
export function getShareInfo(
id: string,
password?: string,
@ -891,6 +920,54 @@ export function getTasks(req: ListTaskService): ThunkResponse<TaskListResponse>
};
}
export function sendCancelTask(id: string): ThunkResponse<undefined> {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/workflow/${id}/cancel`,
{
method: "POST",
},
{
...defaultOpts,
},
),
);
};
}
export function sendDeleteTask(id: string): ThunkResponse<undefined> {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/workflow/${id}`,
{
method: "DELETE",
},
{
...defaultOpts,
},
),
);
};
}
export function sendRetryTask(id: string): ThunkResponse<undefined> {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/workflow/${id}/retry`,
{
method: "POST",
},
{
...defaultOpts,
},
),
);
};
}
export function getTasksPhaseProgress(id: string): ThunkResponse<TaskProgresses> {
return async (dispatch, _getState) => {
return await dispatch(
@ -1275,6 +1352,40 @@ export function sendEmailActivate(id: string, sign: string): ThunkResponse<User>
};
}
export function sendRequestEmailChange(newEmail: string, password: string): ThunkResponse {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/user/setting/email`,
{
method: "POST",
data: { new_email: newEmail, password },
},
{
...defaultOpts,
},
),
);
};
}
export function sendEmailChangeActivate(id: string, sign: string): ThunkResponse {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/user/activate_email/${id}?sign=${encodeURIComponent(sign)}`,
{
method: "GET",
},
{
...defaultOpts,
noCredential: true,
},
),
);
};
}
export function sendResetEmail(req: SendResetEmailService): ThunkResponse<User> {
return async (dispatch, _getState) => {
return await dispatch(
@ -1786,6 +1897,26 @@ export function batchDeleteUser(args: BatchIDService): ThunkResponse<void> {
};
}
export interface BatchUserUpdateService {
ids: number[];
status?: "active" | "inactive" | "manual_banned";
group_id?: number;
}
export function batchUpdateUser(args: BatchUserUpdateService): ThunkResponse<void> {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/admin/user/batch/update`,
{ method: "POST", data: args },
{
...defaultOpts,
},
),
);
};
}
export function getFlattenFileList(args: AdminListService): ThunkResponse<ListFileResponse> {
return async (dispatch, _getState) => {
return await dispatch(
@ -2191,6 +2322,23 @@ export function sendFullTextSearch(query: string, offset?: number): ThunkRespons
};
}
export function sendBlobAuditTask(req: BlobAuditWorkflowService): ThunkResponse<TaskResponse> {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/workflow/blobAudit",
{
data: req,
method: "POST",
},
{
...defaultOpts,
},
),
);
};
}
export function sendRebuildFTSIndex(req: RebuildFTSIndexWorkflowService): ThunkResponse<TaskResponse> {
return async (dispatch, _getState) => {
return await dispatch(

@ -61,6 +61,7 @@ export interface GroupSetting {
max_walked_files?: number;
trash_retention?: number;
redirected_source?: boolean;
login_ip_whitelist?: string[];
}
export interface AdminListGroupResponse {
@ -123,9 +124,15 @@ export interface CommonMixin {
deleted_at?: string;
}
export enum PolicyStatus {
active = "active",
suspended = "suspended",
}
export interface StoragePolicy extends CommonMixin {
name: string;
type: PolicyType;
status?: PolicyStatus;
server?: string;
bucket_name?: string;
is_private?: boolean;
@ -217,6 +224,7 @@ export interface PolicySetting {
is_file_type_deny_list?: boolean;
file_regexp?: string;
is_name_regexp_deny_list?: boolean;
allow_native_name?: boolean;
od_redirect?: string;
custom_proxy?: boolean;
proxy_server?: string;
@ -256,6 +264,9 @@ export interface User extends CommonMixin {
avatar?: string;
credit?: number;
group_expires?: string;
ban_expires?: string;
ban_reason?: string;
last_login?: string;
notify_date?: string;
group_users?: number;
previous_group?: number;

@ -94,6 +94,7 @@ export interface Share {
allow_edit?: boolean;
preview_only?: boolean;
upload_only?: boolean;
note?: string;
}
export enum PolicyType {
@ -264,6 +265,7 @@ export interface UnlockFileService {
export interface RenameFileService {
uri: string;
new_name: string;
expect_id?: string;
}
export const NavigatorCapability = {
@ -292,6 +294,8 @@ export interface PinFileService {
export interface MoveFileService extends MultipleUriService {
dst: string;
copy?: boolean;
expect_ids?: string[];
on_conflict?: "skip" | "overwrite";
}
export interface MetadataPatch {
@ -316,6 +320,7 @@ export interface ShareCreateService {
allow_edit?: boolean;
preview_only?: boolean;
upload_only?: boolean;
note?: string;
}
export interface CreateFileService {

@ -137,6 +137,7 @@ export const Code = {
IncorrectPassword: 40069,
LockConflict: 40073,
StaleVersion: 40076,
ObjectExist: 40004,
BatchOperationNotFullyCompleted: 40081,
DomainNotLicensed: 40087,
AnonymouseAccessDenied: 40088,

@ -30,6 +30,8 @@ export interface SiteConfig {
register_enabled?: boolean;
sso_enabled?: boolean;
sso_display_name?: string;
sso_auto_redirect?: boolean;
download_cdn_routes?: { name: string; url: string }[];
logo?: string;
logo_light?: string;
tos_url?: string;
@ -54,6 +56,9 @@ export interface SiteConfig {
thumb_exts?: string[];
show_encryption_status?: boolean;
full_text_search?: boolean;
remote_download_providers?: string[];
share_default_private?: boolean;
default_share_links_in_profile?: string;
}
export interface CaptchaResponse {
@ -65,6 +70,7 @@ export interface CustomNavItem {
name: string;
url: string;
icon: string;
scope?: "public" | "user";
}
export interface CustomHTML {

@ -1,3 +1,5 @@
import Boolset from "../util/boolset";
/**
* UserLoginService 管理用户登录的服务
*/
@ -24,6 +26,8 @@ export interface User {
language?: string;
disable_view_sync?: boolean;
share_links_in_profile?: ShareLinksInProfileLevel;
share_default_private?: boolean;
preferred_viewers?: Record<string, string>;
}
export interface Group {
id: string;
@ -93,6 +97,40 @@ export const GroupPermission = {
redirected_source: 11,
advance_delete: 12,
unique_direct_link: 17,
webdav_read_only: 18,
admin_users: 19,
admin_groups: 20,
admin_files: 21,
admin_shares: 22,
admin_storage: 23,
admin_queue: 24,
admin_settings: 25,
admin_payment: 26,
admin_events: 27,
admin_reports: 28,
};
// Delegated admin section bits — is_admin implies all of them.
export const DelegatedAdminPermissions = [
GroupPermission.admin_users,
GroupPermission.admin_groups,
GroupPermission.admin_files,
GroupPermission.admin_shares,
GroupPermission.admin_storage,
GroupPermission.admin_queue,
GroupPermission.admin_settings,
GroupPermission.admin_payment,
GroupPermission.admin_events,
GroupPermission.admin_reports,
];
// isAnyAdmin reports whether the permission set grants full or delegated admin
// access to at least one section.
export const isAnyAdmin = (permission: Boolset): boolean => {
return (
permission.enabled(GroupPermission.is_admin) ||
DelegatedAdminPermissions.some((p) => permission.enabled(p))
);
};
export interface UserSettings {
@ -104,6 +142,9 @@ export interface UserSettings {
passkeys?: Passkey[];
disable_view_sync: boolean;
share_links_in_profile: ShareLinksInProfileLevel;
share_default_private?: boolean;
preferred_viewers?: Record<string, string>;
trash_retention?: number;
oauth_grants?: OAuthGrant[];
}
@ -128,6 +169,11 @@ export interface PatchUserSetting {
two_fa_code?: string;
disable_view_sync?: boolean;
share_links_in_profile?: ShareLinksInProfileLevel;
// Tri-state: "true" / "false" / "" (inherit the site default).
share_default_private?: string;
preferred_viewers?: Record<string, string>;
// Trash retention override in seconds; 0 inherits the group setting.
trash_retention?: number;
}
export interface PasskeyCredentialOption {
@ -205,7 +251,8 @@ export interface ResetPasswordService {
}
export enum ShareLinksInProfileLevel {
public_share_only = "",
site_default = "",
public_share_only = "public_share",
all_share = "all_share",
hide_share = "hide_share",
}

@ -101,6 +101,11 @@ export interface DownloadWorkflowService {
src?: string[];
src_file?: string;
dst: string;
file_name?: string;
username?: string;
password?: string;
headers?: string[];
provider?: string;
}
export interface ImportWorkflowService {
@ -138,6 +143,7 @@ export enum TaskType {
full_text_change_owner = "full_text_change_owner",
full_text_delete = "full_text_delete",
full_text_rebuild = "full_text_rebuild",
blob_audit = "blob_audit",
}
export enum TaskStatus {
@ -171,3 +177,8 @@ export interface SetDownloadFilesService {
export interface RebuildFTSIndexWorkflowService {
filtered_storage_policy?: number[];
}
export interface BlobAuditWorkflowService {
policy_id: number;
delete?: boolean;
}

@ -14,6 +14,8 @@ export interface EntityFilterPopoverProps extends PopoverProps {
setOwner: (owner: string) => void;
type?: EntityType;
setType: (type?: EntityType) => void;
refCount: string;
setRefCount: (refCount: string) => void;
clearFilters: () => void;
}
@ -24,6 +26,8 @@ const EntityFilterPopover = ({
setOwner,
type,
setType,
refCount,
setRefCount,
clearFilters,
onClose,
open,
@ -35,6 +39,8 @@ const EntityFilterPopover = ({
const [localStoragePolicy, setLocalStoragePolicy] = useState(storagePolicy);
const [localOwner, setLocalOwner] = useState(owner);
const [localType, setLocalType] = useState(type);
const [localRefCountOp, setLocalRefCountOp] = useState("");
const [localRefCountValue, setLocalRefCountValue] = useState("");
// Initialize local state when popup opens
useEffect(() => {
@ -42,6 +48,9 @@ const EntityFilterPopover = ({
setLocalStoragePolicy(storagePolicy);
setLocalOwner(owner);
setLocalType(type);
const [op, value] = refCount.split(":");
setLocalRefCountOp(refCount ? op : "");
setLocalRefCountValue(value ?? "");
}
}, [open]);
@ -50,6 +59,9 @@ const EntityFilterPopover = ({
setStoragePolicy(localStoragePolicy);
setOwner(localOwner);
setType(localType);
setRefCount(
localRefCountOp == "" ? "" : localRefCountOp == "stale" ? "stale" : `${localRefCountOp}:${localRefCountValue || "0"}`,
);
onClose?.({}, "backdropClick");
};
@ -58,6 +70,8 @@ const EntityFilterPopover = ({
setLocalStoragePolicy("");
setLocalOwner("");
setLocalType(undefined);
setLocalRefCountOp("");
setLocalRefCountValue("");
clearFilters();
onClose?.({}, "backdropClick");
};
@ -128,6 +142,43 @@ const EntityFilterPopover = ({
</DenseSelect>
</SettingForm>
<SettingForm title={t("file.referenceCount")} noContainer lgWidth={12}>
<Stack direction="row" spacing={1}>
<DenseSelect
displayEmpty
value={localRefCountOp}
onChange={(e) => setLocalRefCountOp(e.target.value)}
sx={{ minWidth: 140 }}
>
<SquareMenuItem value="">
<ListItemText primary={<em>{t("user.all")}</em>} slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
<SquareMenuItem value="stale">
<ListItemText primary={t("file.awaitingRecycle")} slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
<SquareMenuItem value="gt">
<ListItemText primary={t("file.refCountGt")} slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
<SquareMenuItem value="lt">
<ListItemText primary={t("file.refCountLt")} slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
<SquareMenuItem value="eq">
<ListItemText primary={t("file.refCountEq")} slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
</DenseSelect>
{localRefCountOp != "" && localRefCountOp != "stale" && (
<DenseFilledTextField
value={localRefCountValue}
onChange={(e) => setLocalRefCountValue(e.target.value.replace(/\D/g, ""))}
placeholder="0"
size="small"
inputProps={{ inputMode: "numeric" }}
sx={{ width: 80 }}
/>
)}
</Stack>
</SettingForm>
<SettingForm title={t("file.storagePolicy")} noContainer lgWidth={12}>
<SinglePolicySelectionInput
value={localStoragePolicy == "" ? -1 : parseInt(localStoragePolicy)}

@ -39,6 +39,7 @@ import EntityRow from "./EntityRow";
export const StoragePolicyQuery = "storage_policy";
export const UserQuery = "user";
export const TypeQuery = "type";
export const RefCountQuery = "ref_count";
const EntitySetting = () => {
const { t } = useTranslation("dashboard");
@ -58,6 +59,7 @@ const EntitySetting = () => {
const [storagePolicy, setStoragePolicy] = useQueryState(StoragePolicyQuery, { defaultValue: "" });
const [user, setUser] = useQueryState(UserQuery, { defaultValue: "" });
const [type, setType] = useQueryState(TypeQuery, { defaultValue: "" });
const [refCount, setRefCount] = useQueryState(RefCountQuery, { defaultValue: "" });
const [count, setCount] = useState(0);
const [selected, setSelected] = useState<readonly number[]>([]);
const filterPopupState = usePopupState({
@ -79,11 +81,12 @@ const EntitySetting = () => {
setStoragePolicy("");
setUser("");
setType("");
}, [setStoragePolicy, setUser, setType]);
setRefCount("");
}, [setStoragePolicy, setUser, setType, setRefCount]);
useEffect(() => {
fetchEntities();
}, [page, pageSize, orderBy, orderDirection, storagePolicy, user, type]);
}, [page, pageSize, orderBy, orderDirection, storagePolicy, user, type, refCount]);
const fetchEntities = () => {
setLoading(true);
@ -109,6 +112,10 @@ const EntitySetting = () => {
params.conditions!.entity_type = type;
}
if (refCount) {
params.conditions!.entity_ref_count = refCount;
}
dispatch(getEntityList(params))
.then((res) => {
setEntities(res.entities);
@ -202,6 +209,8 @@ const EntitySetting = () => {
setOwner={setUser}
type={type !== "" ? parseInt(type) : undefined}
setType={(type) => setType(type !== undefined ? type.toString() : "")}
refCount={refCount}
setRefCount={setRefCount}
clearFilters={clearFilters}
/>

@ -1,7 +1,7 @@
import { Box, Button, Checkbox, Popover, PopoverProps, Stack, styled } from "@mui/material";
import { Box, Button, Checkbox, MenuItem, Popover, PopoverProps, Stack, styled } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DenseFilledTextField, SmallFormControlLabel } from "../../Common/StyledComponents";
import { DenseFilledTextField, DenseSelect, SmallFormControlLabel } from "../../Common/StyledComponents";
import SettingForm from "../../Pages/Setting/SettingForm";
import SinglePolicySelectionInput from "../Common/SinglePolicySelectionInput";
@ -18,6 +18,8 @@ export interface FileFilterPopoverProps extends PopoverProps {
setHasDirectLink: (hasDirectLink: boolean) => void;
isUploading: boolean;
setIsUploading: (isUploading: boolean) => void;
deleted: string;
setDeleted: (deleted: string) => void;
clearFilters: () => void;
}
@ -40,6 +42,8 @@ const FileFilterPopover = ({
setHasDirectLink,
isUploading,
setIsUploading,
deleted,
setDeleted,
clearFilters,
onClose,
open,
@ -54,6 +58,7 @@ const FileFilterPopover = ({
const [localHasShareLink, setLocalHasShareLink] = useState(hasShareLink);
const [localHasDirectLink, setLocalHasDirectLink] = useState(hasDirectLink);
const [localIsUploading, setLocalIsUploading] = useState(isUploading);
const [localDeleted, setLocalDeleted] = useState(deleted);
// Initialize local state when popup opens
useEffect(() => {
@ -64,6 +69,7 @@ const FileFilterPopover = ({
setLocalHasShareLink(hasShareLink);
setLocalHasDirectLink(hasDirectLink);
setLocalIsUploading(isUploading);
setLocalDeleted(deleted);
}
}, [open]);
@ -75,6 +81,7 @@ const FileFilterPopover = ({
setHasShareLink(localHasShareLink);
setHasDirectLink(localHasDirectLink);
setIsUploading(localIsUploading);
setDeleted(localDeleted);
onClose?.({}, "backdropClick");
};
@ -86,6 +93,7 @@ const FileFilterPopover = ({
setLocalHasShareLink(false);
setLocalHasDirectLink(false);
setLocalIsUploading(false);
setLocalDeleted("");
clearFilters();
onClose?.({}, "backdropClick");
};
@ -143,6 +151,19 @@ const FileFilterPopover = ({
/>
</SettingForm>
<SettingForm title={t("file.deletedState")} noContainer lgWidth={12}>
<DenseSelect
fullWidth
size="small"
value={localDeleted}
onChange={(e) => setLocalDeleted(e.target.value as string)}
>
<MenuItem value="">{t("user.all")}</MenuItem>
<MenuItem value="false">{t("file.liveOnly")}</MenuItem>
<MenuItem value="true">{t("file.trashOnly")}</MenuItem>
</DenseSelect>
</SettingForm>
<SettingForm title={t("file.otherConditions")} noContainer lgWidth={12}>
<Stack spacing={0.5}>
<SmallFormControlLabel

@ -46,6 +46,7 @@ export const NameQuery = "name";
export const HasDirectLinkQuery = "has_direct_link";
export const SharedQuery = "shared";
export const UploadingQuery = "uploading";
export const DeletedQuery = "deleted";
const FileSetting = () => {
const { t } = useTranslation("dashboard");
@ -68,6 +69,7 @@ const FileSetting = () => {
const [hasDirectLink, setHasDirectLink] = useQueryState(HasDirectLinkQuery, { defaultValue: "" });
const [shared, setShared] = useQueryState(SharedQuery, { defaultValue: "" });
const [uploading, setUploading] = useQueryState(UploadingQuery, { defaultValue: "" });
const [deleted, setDeleted] = useQueryState(DeletedQuery, { defaultValue: "" });
const [count, setCount] = useState(0);
const [selected, setSelected] = useState<readonly number[]>([]);
const [createNewOpen, setCreateNewOpen] = useState(false);
@ -93,11 +95,12 @@ const FileSetting = () => {
setHasDirectLink("");
setShared("");
setUploading("");
}, [setStoragePolicy, setOwner, setName, setHasDirectLink, setShared, setUploading]);
setDeleted("");
}, [setStoragePolicy, setOwner, setName, setHasDirectLink, setShared, setUploading, setDeleted]);
useEffect(() => {
fetchFiles();
}, [page, pageSize, orderBy, orderDirection, storagePolicy, owner, name, hasDirectLink, shared, uploading]);
}, [page, pageSize, orderBy, orderDirection, storagePolicy, owner, name, hasDirectLink, shared, uploading, deleted]);
const fetchFiles = () => {
setLoading(true);
@ -115,6 +118,7 @@ const FileSetting = () => {
file_direct_link: hasDirectLink === "true" ? "true" : "",
file_shared: shared === "true" ? "true" : "",
file_metadata: uploading === "true" ? Metadata.upload_session_id : "",
file_deleted: deleted,
},
}),
)
@ -183,8 +187,8 @@ const FileSetting = () => {
};
const hasActiveFilters = useMemo(() => {
return !!(storagePolicy || owner || name || hasDirectLink || shared || uploading);
}, [storagePolicy, owner, name, hasDirectLink, shared, uploading]);
return !!(storagePolicy || owner || name || hasDirectLink || shared || uploading || deleted);
}, [storagePolicy, owner, name, hasDirectLink, shared, uploading, deleted]);
const handleFileDialogOpen = (id: number) => {
setFileDialogID(id);
@ -239,6 +243,8 @@ const FileSetting = () => {
setHasShareLink={(value: boolean) => setShared(value ? "true" : "")}
isUploading={uploading === "true"}
setIsUploading={(value: boolean) => setUploading(value ? "true" : "")}
deleted={deleted}
setDeleted={setDeleted}
/>
<SecondaryButton onClick={fetchFiles} disabled={loading} variant={"contained"} startIcon={<ArrowSync />}>

@ -170,7 +170,7 @@ function DraggableTemplateRow({
hover
>
<NoWrapTableCell>
<DenseSelect value={template.ext} required onChange={onExtChange}>
<DenseSelect value={template.ext} required onChange={onExtChange} fullWidth>
{extList.map((ext) => (
<SquareMenuItem value={ext} key={ext}>
<ListItemText slotProps={{ primary: { variant: "body2" } }}>{ext}</ListItemText>

@ -57,6 +57,30 @@ const BasicInfoSection = () => {
[setGroup],
);
const onSectionChange = useCallback(
(bit: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(bit, e.target.checked).toString(),
}));
},
[setGroup],
);
const onWhitelistChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.value
.split("\n")
.map((l) => l.trim())
.filter((l) => l != "");
setGroup((p: GroupEnt) => ({
...p,
settings: { ...p.settings, login_ip_whitelist: list.length > 0 ? list : undefined },
}));
},
[setGroup],
);
return (
<SettingSection>
<Typography variant="h6" gutterBottom>
@ -105,6 +129,45 @@ const BasicInfoSection = () => {
<NoMarginHelperText>{t("group.isAdminDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
{!permission.enabled(GroupPermission.is_admin) && (
<SettingForm title={t("group.delegatedAdmin")} lgWidth={5}>
<FormControl fullWidth>
<NoMarginHelperText>{t("group.delegatedAdminDes")}</NoMarginHelperText>
{(
[
[GroupPermission.admin_users, "group.sectionUsers"],
[GroupPermission.admin_groups, "group.sectionGroups"],
[GroupPermission.admin_files, "group.sectionFiles"],
[GroupPermission.admin_shares, "group.sectionShares"],
[GroupPermission.admin_storage, "group.sectionStorage"],
[GroupPermission.admin_queue, "group.sectionQueue"],
[GroupPermission.admin_settings, "group.sectionSettings"],
[GroupPermission.admin_payment, "group.sectionPayment"],
[GroupPermission.admin_events, "group.sectionEvents"],
[GroupPermission.admin_reports, "group.sectionReports"],
] as [number, string][]
).map(([bit, label]) => (
<FormControlLabel
key={bit}
control={<Switch checked={permission.enabled(bit)} onChange={onSectionChange(bit)} />}
label={t(label)}
/>
))}
</FormControl>
</SettingForm>
)}
<SettingForm title={t("group.loginIPWhitelist")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
multiline
minRows={2}
value={(values?.settings?.login_ip_whitelist ?? []).join("\n")}
onChange={onWhitelistChange}
placeholder={"10.0.0.0/8"}
/>
<NoMarginHelperText>{t("group.loginIPWhitelistDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</>
)}
</SettingSectionContent>

@ -63,6 +63,16 @@ const FileManagementSection = () => {
[setGroup],
);
const onWabDAVReadOnlyChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(GroupPermission.webdav_read_only, e.target.checked).toString(),
}));
},
[setGroup],
);
const onAllowCompressTaskChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
@ -186,6 +196,20 @@ const FileManagementSection = () => {
<NoMarginHelperText>{t("group.allowWabDAVProxyDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={permission.enabled(GroupPermission.webdav_read_only)}
onChange={onWabDAVReadOnlyChange}
/>
}
label={t("group.wabDAVReadOnly")}
/>
<NoMarginHelperText>{t("group.wabDAVReadOnlyDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</Collapse>
<SettingForm lgWidth={5}>
<FormControl fullWidth>

@ -2,6 +2,7 @@ import { Icon } from "@iconify/react/dist/iconify.js";
import {
Box,
IconButton,
MenuItem,
Table,
TableBody,
TableCell,
@ -18,6 +19,7 @@ import { useTranslation } from "react-i18next";
import { CustomNavItem } from "../../../../api/site";
import {
DenseFilledTextField,
DenseSelect,
NoWrapCell,
NoWrapTableCell,
SecondaryButton,
@ -49,6 +51,7 @@ interface DraggableNavItemRowProps {
inputCache: { [key: number]: { [field: string]: string | undefined } };
onInputChange: (index: number, field: string, value: string) => void;
onInputBlur: (index: number, field: keyof CustomNavItem) => void;
onFieldChange: (index: number, field: keyof CustomNavItem, value: string) => void;
IconPreview: React.ComponentType<{ iconName: string }>;
t: any;
style?: React.CSSProperties;
@ -69,6 +72,7 @@ const DraggableNavItemRow = React.memo(
inputCache,
onInputChange,
onInputBlur,
onFieldChange,
IconPreview,
t,
style,
@ -144,6 +148,17 @@ const DraggableNavItemRow = React.memo(
placeholder="https://example.com"
/>
</TableCell>
<TableCell>
<DenseSelect
fullWidth
size="small"
value={item.scope === "user" ? "user" : "public"}
onChange={(e) => onFieldChange(index, "scope", e.target.value as string)}
>
<MenuItem value="public">{t("settings.navScopePublic")}</MenuItem>
<MenuItem value="user">{t("settings.navScopeUser")}</MenuItem>
</DenseSelect>
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => onDelete(index)}>
<Delete fontSize="small" />
@ -327,6 +342,7 @@ const CustomNavItems = ({ value, onChange }: CustomNavItemsProps) => {
<NoWrapTableCell width={200}>{t("settings.iconifyName")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("settings.displayName")}</NoWrapTableCell>
<NoWrapTableCell width={250}>{t("settings.navItemUrl")}</NoWrapTableCell>
<NoWrapTableCell width={130}>{t("settings.navItemVisibility")}</NoWrapTableCell>
<NoWrapTableCell width={80} align="right"></NoWrapTableCell>
<NoWrapTableCell width={80}></NoWrapTableCell>
</TableRow>
@ -349,6 +365,7 @@ const CustomNavItems = ({ value, onChange }: CustomNavItemsProps) => {
inputCache={inputCache}
onInputChange={handleInputChange}
onInputBlur={handleInputBlur}
onFieldChange={handleFieldChange}
IconPreview={IconPreview}
t={t}
/>
@ -356,7 +373,7 @@ const CustomNavItems = ({ value, onChange }: CustomNavItemsProps) => {
})}
{items.length === 0 && (
<TableRow>
<NoWrapCell colSpan={6} align="center">
<NoWrapCell colSpan={7} align="center">
<Typography variant="caption" color="text.secondary">
{t("application:setting.listEmpty")}
</Typography>

@ -36,6 +36,7 @@ export interface ThemeOptionsProps {
interface ThemeOption {
id: string;
config: {
hidden?: boolean;
light: {
palette: {
primary: {
@ -234,6 +235,15 @@ const ThemeOptions = ({ value, onChange, defaultTheme, onDefaultThemeChange }: T
[onDefaultThemeChange],
);
const handleVisibilityChange = useCallback(
(id: string, visible: boolean) => {
const newOptions = { ...options };
newOptions[id] = { ...newOptions[id], hidden: !visible };
handleSave(newOptions);
},
[options, handleSave],
);
const optionsArray = useMemo(() => {
return Object.entries(options).map(([id, config]) => ({
id,
@ -256,6 +266,7 @@ const ThemeOptions = ({ value, onChange, defaultTheme, onDefaultThemeChange }: T
<TableHead>
<TableRow>
<NoWrapTableCell width={50}>{t("settings.defaultTheme")}</NoWrapTableCell>
<NoWrapTableCell width={50}>{t("settings.themeVisible")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("settings.primaryColor")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("settings.secondaryColor")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("settings.primaryColorDark")}</NoWrapTableCell>
@ -273,6 +284,13 @@ const ThemeOptions = ({ value, onChange, defaultTheme, onDefaultThemeChange }: T
onChange={() => handleDefaultThemeChange(option.id)}
/>
</TableCell>
<TableCell>
<StyledCheckbox
size="small"
checked={!option.config.hidden}
onChange={(e) => handleVisibilityChange(option.id, e.target.checked)}
/>
</TableCell>
<TableCell>
<HexColorInput
required

@ -164,6 +164,7 @@ const Settings = () => {
"siteName",
"siteDes",
"siteURL",
"download_cdn_routes",
"siteScript",
"pwa_small_icon",
"pwa_medium_icon",
@ -198,9 +199,11 @@ const Settings = () => {
"sso_client_secret",
"sso_scopes",
"sso_register_enabled",
"sso_auto_redirect",
"email_filter_mode",
"email_filter_list",
"email_disable_subaddress",
"email_subaddress_chars",
"siteURL",
]}
>

@ -52,6 +52,19 @@ const SiteInformation = () => {
<SiteURLInput urls={values.siteURL} onChange={(v) => setSettings({ siteURL: v })} />
</FormControl>
</SettingForm>
<SettingForm title={t("settings.downloadCdnRoutes")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
onChange={(e) => setSettings({ download_cdn_routes: e.target.value })}
value={values.download_cdn_routes}
multiline
rows={3}
placeholder={"Line 1=https://cdn1.example.com\nLine 2=https://cdn2.example.com"}
/>
<NoMarginHelperText>{t("settings.downloadCdnRoutesDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.customFooterHTML")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField

@ -141,6 +141,28 @@ const SSOSettings = () => {
/>
<NoMarginHelperText>{t("settings.ssoRegisterEnabledDes")}</NoMarginHelperText>
</FormControl>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.sso_auto_redirect)}
onChange={(e) =>
setSettings({
sso_auto_redirect: e.target.checked ? "1" : "0",
})
}
/>
}
label={<Typography variant="body2">{t("settings.ssoAutoRedirect")}</Typography>}
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.ssoAutoRedirectDes"
ns="dashboard"
components={[<Code key="0" />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingSectionContent>
</AccordionDetails>
</StyledAccordion>

@ -139,6 +139,49 @@ const UserSession = () => {
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.share_default_private)}
onChange={(e) =>
setSettings({
share_default_private: e.target.checked ? "1" : "0",
})
}
/>
}
label={t("settings.shareDefaultPrivate")}
/>
<NoMarginHelperText>{t("settings.shareDefaultPrivateDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.defaultShareLinksInProfile")} lgWidth={5}>
<FormControl>
<DenseSelect
value={values.default_share_links_in_profile ?? ""}
onChange={(e) =>
setSettings({
default_share_links_in_profile: e.target.value as string,
})
}
>
{["profileSharePublicOnly", "profileShareAll", "profileShareHide"].map((v, i) => (
<SquareMenuItem key={v} value={["", "all_share", "hide_share"][i]}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t(`settings.${v}`)}
</ListItemText>
</SquareMenuItem>
))}
</DenseSelect>
<NoMarginHelperText>{t("settings.defaultShareLinksInProfileDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("vas.filterEmailProvider")} lgWidth={5}>
<FormControl>
<DenseSelect
@ -208,6 +251,21 @@ const UserSession = () => {
</NoMarginHelperText>
</FormControl>
</SettingForm>
{isTrueVal(values.email_disable_subaddress) && (
<SettingForm title={t("vas.subAddressChars")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
placeholder="+"
value={values.email_subaddress_chars ?? ""}
onChange={(e) => setSettings({ email_subaddress_chars: e.target.value })}
/>
<NoMarginHelperText>
<Trans i18nKey="vas.subAddressCharsDes" ns={"dashboard"} components={[<Code />]} />
</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
</SettingSectionContent>
</SettingSection>
<SettingSection>

@ -0,0 +1,84 @@
import {
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
FormControlLabel,
} from "@mui/material";
import { useSnackbar } from "notistack";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { sendBlobAuditTask } from "../../../api/api";
import { StoragePolicy } from "../../../api/dashboard";
import { useAppDispatch } from "../../../redux/hooks";
import { DefaultCloseAction } from "../../Common/Snackbar/snackbar";
export interface BlobAuditDialogProps {
open: boolean;
onClose: () => void;
policy?: StoragePolicy;
}
const BlobAuditDialog = ({ open, onClose, policy }: BlobAuditDialogProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const { enqueueSnackbar } = useSnackbar();
const [deleteOrphans, setDeleteOrphans] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
setDeleteOrphans(false);
}
}, [open]);
const onSubmit = () => {
if (!policy?.id) {
return;
}
setLoading(true);
dispatch(sendBlobAuditTask({ policy_id: policy.id, delete: deleteOrphans }))
.then(() => {
enqueueSnackbar(t("policy.blobAuditSubmitted"), { variant: "success", action: DefaultCloseAction });
onClose();
})
.finally(() => {
setLoading(false);
});
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>{t("policy.blobAudit", { name: policy?.name ?? "" })}</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>{t("policy.blobAuditDes")}</DialogContentText>
<FormControlLabel
control={
<Checkbox
size="small"
checked={deleteOrphans}
onChange={(e) => setDeleteOrphans(e.target.checked)}
/>
}
label={t("policy.blobAuditDelete")}
/>
{deleteOrphans && (
<DialogContentText color="error" variant="body2">
{t("policy.blobAuditDeleteWarning")}
</DialogContentText>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t("common:cancel")}</Button>
<Button variant="contained" color={deleteOrphans ? "error" : "primary"} onClick={onSubmit} disabled={loading}>
{t("policy.blobAuditStart")}
</Button>
</DialogActions>
</Dialog>
);
};
export default BlobAuditDialog;

@ -14,7 +14,7 @@ import { useCallback, useContext, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import { createStoragePolicyCors, getOneDriveDriverRoot } from "../../../../../api/api";
import { StoragePolicy } from "../../../../../api/dashboard";
import { PolicyStatus, StoragePolicy } from "../../../../../api/dashboard";
import { PolicyType } from "../../../../../api/explorer";
import { useAppDispatch } from "../../../../../redux/hooks";
import { DefaultCloseAction } from "../../../../Common/Snackbar/snackbar";
@ -291,6 +291,21 @@ const BasicInfoSection = () => {
<NoMarginHelperText>{t("policy.policyName")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("policy.status")} lgWidth={5}>
<FormControl fullWidth>
<DenseSelect
value={values.status ?? PolicyStatus.active}
onChange={(e) => setPolicy((prev) => ({ ...prev, status: e.target.value as PolicyStatus }))}
>
{Object.values(PolicyStatus).map((value) => (
<SquareMenuItem value={value} key={value}>
<ListItemText slotProps={{ primary: { variant: "body2" } }}>{t(`node.${value}`)}</ListItemText>
</SquareMenuItem>
))}
</DenseSelect>
<NoMarginHelperText>{t("policy.statusDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
{showBucket && (
<>
<SettingForm title={t(policyProps.bucketName ?? "")} lgWidth={5}>

@ -170,6 +170,16 @@ const StorageAndUploadSection = () => {
[setPolicy],
);
const onNativeNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setPolicy((p: StoragePolicy) => ({
...p,
settings: { ...p.settings, allow_native_name: e.target.checked ? true : undefined },
}));
},
[setPolicy],
);
const onChunkSizeChange = useCallback(
(size: number) => {
setPolicy((p: StoragePolicy) => ({
@ -330,6 +340,17 @@ const StorageAndUploadSection = () => {
<NoMarginHelperText>{t("policy.fileNameRegexDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch checked={values.settings?.allow_native_name ?? false} onChange={onNativeNameChange} />
}
label={t("policy.allowNativeName")}
/>
<NoMarginHelperText>{t("policy.allowNativeNameDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
{values.type !== PolicyType.upyun && (
<SettingForm title={t("policy.chunkSize")} lgWidth={5}>
<FormControl fullWidth>

@ -1,10 +1,11 @@
import { Box, Divider, IconButton, Link, Skeleton, Typography } from "@mui/material";
import { FindInPage } from "@mui/icons-material";
import { Box, Divider, IconButton, Link, Skeleton, Tooltip, Typography } from "@mui/material";
import Grid from "@mui/material/Grid2";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { deleteStoragePolicy, getStoragePolicyDetail } from "../../../api/api";
import { StoragePolicy } from "../../../api/dashboard";
import { PolicyStatus, StoragePolicy } from "../../../api/dashboard";
import { useAppDispatch } from "../../../redux/hooks";
import { confirmOperation } from "../../../redux/thunks/dialog";
import { sizeToString } from "../../../util";
@ -12,6 +13,7 @@ import { NoWrapBox, SquareChip } from "../../Common/StyledComponents";
import Delete from "../../Icons/Delete";
import Info from "../../Icons/Info";
import { BorderedCardClickableBaImg } from "../Common/AdminCard";
import BlobAuditDialog from "./BlobAuditDialog";
import { PolicyPropsMap } from "./StoragePolicySetting";
export interface StoragePolicyCardProps {
@ -26,8 +28,14 @@ const StoragePolicyCard = ({ policy, onRefresh, loading }: StoragePolicyCardProp
const [detail, setDetail] = useState<StoragePolicy | undefined>(undefined);
const [deleteLoading, setDeleteLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [auditOpen, setAuditOpen] = useState(false);
const navigate = useNavigate();
const handleAuditOpen = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setAuditOpen(true);
}, []);
const loadDetail = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
@ -125,6 +133,9 @@ const StoragePolicyCard = ({ policy, onRefresh, loading }: StoragePolicyCardProp
>
<Typography variant="subtitle1" fontWeight={600}>
{policy?.name}
{policy?.status == PolicyStatus.suspended && (
<SquareChip sx={{ ml: 1 }} size="small" color="warning" label={t("node.suspended")} />
)}
</Typography>
{policy && (
<Typography variant="body2" color="text.secondary">
@ -166,11 +177,19 @@ const StoragePolicyCard = ({ policy, onRefresh, loading }: StoragePolicyCardProp
)}
</Typography>
<IconButton size="small" onClick={handleDelete}>
<Delete fontSize="small" />
</IconButton>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Tooltip title={t("policy.blobAudit", { name: policy?.name ?? "" })}>
<IconButton size="small" onClick={handleAuditOpen} disabled={deleteLoading}>
<FindInPage fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={handleDelete}>
<Delete fontSize="small" />
</IconButton>
</Box>
</Box>
</BorderedCardClickableBaImg>
<BlobAuditDialog open={auditOpen} onClose={() => setAuditOpen(false)} policy={policy} />
</Grid>
);
};

@ -133,6 +133,11 @@ export const TaskContent = memo(({ task, openEntity, openFile }: TaskContentProp
return t("task.fullTextDelete", {
count: privateState?.file_ids?.length ?? 0,
});
case TaskType.blob_audit:
return t("task.blobAudit", {
policyID: privateState?.policy_id ?? 0,
count: privateState?.orphan_count ?? 0,
});
default:
return "";
}

@ -0,0 +1,101 @@
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, ListItemText, Stack } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { batchUpdateUser } from "../../../api/api";
import { UserStatus } from "../../../api/dashboard";
import { useAppDispatch } from "../../../redux/hooks";
import { DenseSelect } from "../../Common/StyledComponents";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu";
import SettingForm from "../../Pages/Setting/SettingForm";
import GroupSelectionInput from "../Common/GroupSelectionInput";
export interface BatchUserDialogProps {
open: boolean;
onClose: () => void;
ids: number[];
onUpdated?: () => void;
}
const NoChange = " ";
const BatchUserDialog = ({ open, onClose, ids, onUpdated }: BatchUserDialogProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [status, setStatus] = useState(NoChange);
const [group, setGroup] = useState(NoChange);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
setStatus(NoChange);
setGroup(NoChange);
}
}, [open]);
const onSubmit = () => {
setLoading(true);
dispatch(
batchUpdateUser({
ids,
status: status === NoChange ? undefined : (status as "active" | "inactive" | "manual_banned"),
group_id: group === NoChange ? undefined : parseInt(group),
}),
)
.then(() => {
onUpdated?.();
onClose();
})
.finally(() => {
setLoading(false);
});
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>{t("user.batchEditXUsers", { num: ids.length })}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<SettingForm title={t("user.status")} noContainer lgWidth={12}>
<FormControl fullWidth>
<DenseSelect value={status} onChange={(e) => setStatus(e.target.value as string)}>
<SquareMenuItem value={NoChange}>
<ListItemText slotProps={{ primary: { variant: "body2" } }}>
<em>{t("user.noChange")}</em>
</ListItemText>
</SquareMenuItem>
{Object.values(UserStatus)
.filter((value) => value !== UserStatus.sys_banned)
.map((value) => (
<SquareMenuItem value={value} key={value}>
<ListItemText slotProps={{ primary: { variant: "body2" } }}>{t(`user.status_${value}`)}</ListItemText>
</SquareMenuItem>
))}
</DenseSelect>
</FormControl>
</SettingForm>
<SettingForm title={t("user.group")} noContainer lgWidth={12}>
<GroupSelectionInput
value={group}
onChange={setGroup}
emptyValue={NoChange}
emptyText={t("user.noChange")}
fullWidth
/>
</SettingForm>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t("common:cancel")}</Button>
<Button
variant="contained"
onClick={onSubmit}
disabled={loading || ids.length === 0 || (status === NoChange && group === NoChange)}
>
{t("user.apply")}
</Button>
</DialogActions>
</Dialog>
);
};
export default BatchUserDialog;

@ -65,6 +65,30 @@ const UserForm = ({ reload, setLoading }: { reload: () => void; setLoading: (loa
[setUser],
);
const banned = values.status == UserStatus.manual_banned || values.status == UserStatus.sys_banned;
// datetime-local inputs want "YYYY-MM-DDTHH:mm" in local time; the API
// stores RFC3339.
const toLocalInput = (iso?: string) => {
if (!iso) {
return "";
}
const d = new Date(iso);
if (isNaN(d.getTime())) {
return "";
}
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const onBanExpiresChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setUser((prev) => ({ ...prev, ban_expires: v ? new Date(v).toISOString() : undefined }));
},
[setUser],
);
const onGroupChange = useCallback(
(value: string) => {
setUser((prev) => ({ ...prev, group_users: parseInt(value) }));
@ -179,6 +203,28 @@ const UserForm = ({ reload, setLoading }: { reload: () => void; setLoading: (loa
</DenseSelect>
</FormControl>
</SettingForm>
{banned && (
<>
<SettingForm title={t("user.banExpires")} noContainer lgWidth={6}>
<DenseFilledTextField
fullWidth
type="datetime-local"
value={toLocalInput(values.ban_expires)}
onChange={onBanExpiresChange}
slotProps={{ inputLabel: { shrink: true } }}
/>
<NoMarginHelperText>{t("user.banExpiresDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("user.banReason")} noContainer lgWidth={6}>
<DenseFilledTextField
fullWidth
value={values.ban_reason ?? ""}
onChange={(e) => setUser((prev) => ({ ...prev, ban_reason: e.target.value }))}
/>
<NoMarginHelperText>{t("user.banReasonDes")}</NoMarginHelperText>
</SettingForm>
</>
)}
<SettingForm title={t("user.group")} noContainer lgWidth={6}>
<GroupSelectionInput value={values.group_users?.toString() ?? ""} onChange={onGroupChange} fullWidth />
</SettingForm>

@ -16,6 +16,8 @@ export interface UserFilterPopoverProps extends PopoverProps {
setGroup: (group: string) => void;
status: string;
setStatus: (status: string) => void;
uids: string;
setUids: (uids: string) => void;
clearFilters: () => void;
}
@ -28,6 +30,8 @@ const UserFilterPopover = ({
setGroup,
status,
setStatus,
uids,
setUids,
clearFilters,
onClose,
open,
@ -40,6 +44,7 @@ const UserFilterPopover = ({
const [localNick, setLocalNick] = useState(nick);
const [localGroup, setLocalGroup] = useState(group);
const [localStatus, setLocalStatus] = useState(status);
const [localUids, setLocalUids] = useState(uids);
// Initialize local state when popup opens
useEffect(() => {
@ -48,6 +53,7 @@ const UserFilterPopover = ({
setLocalNick(nick);
setLocalGroup(group);
setLocalStatus(status);
setLocalUids(uids);
}
}, [open]);
@ -57,6 +63,7 @@ const UserFilterPopover = ({
setNick(localNick);
setGroup(localGroup == " " ? "" : localGroup);
setStatus(localStatus == " " ? "" : localStatus);
setUids(localUids);
onClose?.({}, "backdropClick");
};
@ -66,6 +73,7 @@ const UserFilterPopover = ({
setLocalNick("");
setLocalGroup("");
setLocalStatus("");
setLocalUids("");
clearFilters();
onClose?.({}, "backdropClick");
};
@ -114,6 +122,16 @@ const UserFilterPopover = ({
/>
</SettingForm>
<SettingForm title={t("user.uids")} noContainer lgWidth={12}>
<DenseFilledTextField
fullWidth
value={localUids}
onChange={(e) => setLocalUids(e.target.value)}
placeholder={t("user.uidsPlaceholder")}
size="small"
/>
</SettingForm>
<SettingForm title={t("user.group")} noContainer lgWidth={12}>
<GroupSelectionInput
value={localGroup == "" ? " " : localGroup}

@ -8,6 +8,7 @@ import { useAppDispatch } from "../../../redux/hooks";
import { confirmOperation } from "../../../redux/thunks/dialog";
import { sizeToString } from "../../../util";
import { NoWrapTableCell, NoWrapTypography, SquareChip } from "../../Common/StyledComponents";
import TimeBadge from "../../Common/TimeBadge";
import UserAvatar from "../../Common/User/UserAvatar";
import Delete from "../../Icons/Delete";
import PersonPasskey from "../../Icons/PersonPasskey";
@ -99,6 +100,9 @@ const UserRow = ({ user, loading, deleting, selected, onDelete, onDetails, onSel
<NoWrapTableCell>
<Skeleton variant="text" width={100} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={100} />
</NoWrapTableCell>
</TableRow>
);
}
@ -164,6 +168,15 @@ const UserRow = ({ user, loading, deleting, selected, onDelete, onDetails, onSel
</NoWrapTypography>
</NoWrapTableCell>
<NoWrapTableCell>{sizeToString(user?.storage ?? 0)}</NoWrapTableCell>
<NoWrapTableCell>
{user?.last_login ? (
<TimeBadge datetime={user.last_login} variant="inherit" />
) : (
<NoWrapTypography variant="inherit" color="text.secondary">
-
</NoWrapTypography>
)}
</NoWrapTableCell>
<NoWrapTableCell>
<IconButton size="small" onClick={onDeleteClick} disabled={deleteLoading || deleting}>
<Delete fontSize="small" />

@ -1,4 +1,4 @@
import { Delete } from "@mui/icons-material";
import { Delete, Edit } from "@mui/icons-material";
import {
Badge,
Box,
@ -33,6 +33,7 @@ import PageContainer from "../../Pages/PageContainer";
import PageHeader from "../../Pages/PageHeader";
import TablePagination from "../Common/TablePagination";
import { OrderByQuery, OrderDirectionQuery, PageQuery, PageSizeQuery } from "../StoragePolicy/StoragePolicySetting";
import BatchUserDialog from "./BatchUserDialog";
import NewUserDialog from "./NewUserDialog";
import UserDialog from "./UserDialog/UserDialog";
import UserFilterPopover from "./UserFilterPopover";
@ -41,6 +42,7 @@ export const EmailQuery = "email";
export const NickQuery = "nick";
export const GroupQuery = "group";
export const StatusQuery = "status";
export const UidsQuery = "uids";
const UserSetting = () => {
const { t } = useTranslation("dashboard");
@ -61,9 +63,11 @@ const UserSetting = () => {
const [nick, setNick] = useQueryState(NickQuery, { defaultValue: "" });
const [group, setGroup] = useQueryState(GroupQuery, { defaultValue: "" });
const [status, setStatus] = useQueryState(StatusQuery, { defaultValue: "" });
const [uids, setUids] = useQueryState(UidsQuery, { defaultValue: "" });
const [count, setCount] = useState(0);
const [selected, setSelected] = useState<readonly number[]>([]);
const [createNewOpen, setCreateNewOpen] = useState(false);
const [batchEditOpen, setBatchEditOpen] = useState(false);
const filterPopupState = usePopupState({
variant: "popover",
popupId: "userFilterPopover",
@ -81,11 +85,12 @@ const UserSetting = () => {
setNick("");
setGroup("");
setStatus("");
}, [setEmail, setNick, setGroup, setStatus]);
setUids("");
}, [setEmail, setNick, setGroup, setStatus, setUids]);
useEffect(() => {
fetchUsers();
}, [page, pageSize, orderBy, orderDirection, email, nick, group, status]);
}, [page, pageSize, orderBy, orderDirection, email, nick, group, status, uids]);
const fetchUsers = () => {
setLoading(true);
@ -101,6 +106,7 @@ const UserSetting = () => {
user_nick: nick,
user_group: group,
user_status: status,
user_ids: uids,
},
}),
)
@ -169,8 +175,8 @@ const UserSetting = () => {
};
const hasActiveFilters = useMemo(() => {
return !!(email || nick || group || status);
}, [email, nick, group, status]);
return !!(email || nick || group || status || uids);
}, [email, nick, group, status, uids]);
const handleUserDialogOpen = (id: number) => {
setUserDialogID(id);
@ -193,6 +199,12 @@ const UserSetting = () => {
userID={userDialogID}
onUpdated={() => fetchUsers()}
/>
<BatchUserDialog
open={batchEditOpen}
onClose={() => setBatchEditOpen(false)}
ids={Array.from(selected)}
onUpdated={() => fetchUsers()}
/>
<Container maxWidth="xl">
<PageHeader title={t("dashboard:nav.users")} />
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
@ -210,6 +222,8 @@ const UserSetting = () => {
setGroup={setGroup}
status={status}
setStatus={setStatus}
uids={uids}
setUids={setUids}
clearFilters={clearFilters}
/>
@ -226,6 +240,9 @@ const UserSetting = () => {
{selected.length > 0 && !isMobile && (
<>
<Divider orientation="vertical" flexItem />
<Button startIcon={<Edit />} variant="contained" onClick={() => setBatchEditOpen(true)}>
{t("user.editXUsers", { num: selected.length })}
</Button>
<Button startIcon={<Delete />} variant="contained" color="error" onClick={handleDelete}>
{t("user.deleteXUsers", { num: selected.length })}
</Button>
@ -234,6 +251,9 @@ const UserSetting = () => {
</Stack>
{isMobile && selected.length > 0 && (
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Button startIcon={<Edit />} variant="contained" onClick={() => setBatchEditOpen(true)}>
{t("user.editXUsers", { num: selected.length })}
</Button>
<Button startIcon={<Delete />} variant="contained" color="error" onClick={handleDelete}>
{t("user.deleteXUsers", { num: selected.length })}
</Button>
@ -274,6 +294,15 @@ const UserSetting = () => {
{t("user.usedStorage")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={150}>
<TableSortLabel
active={orderBy === "last_login"}
direction={direction}
onClick={onSortClick("last_login")}
>
{t("user.lastLogin")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={100} align="right"></NoWrapTableCell>
</TableRow>
</TableHead>

@ -119,6 +119,7 @@ const UserAvatar = memo(
img: {
loading: "lazy",
alt: "",
draggable: false,
},
}}
{...rest}

@ -1,5 +1,5 @@
import { Box, Divider, ListItemIcon, ListItemText, Menu, MenuItem, styled, Typography, useTheme } from "@mui/material";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { closeContextMenu } from "../../../redux/fileManagerSlice.ts";
import { CreateNewDialogType } from "../../../redux/globalStateSlice.ts";
@ -107,6 +107,23 @@ const ContextMenu = ({ fmIndex = 0 }: ContextMenuProps) => {
dispatch(closeContextMenu({ index: fmIndex, value: undefined }));
}, [dispatch]);
// The menu overlay lets pointer events fall through to the file list, so
// dismiss it manually on any click outside the menu paper — the same event
// still reaches the file underneath (upstream #3278).
useEffect(() => {
if (!contextMenuOpen) {
return;
}
const onPointerDown = (e: PointerEvent) => {
if ((e.target as HTMLElement | null)?.closest?.(".MuiMenu-paper")) {
return;
}
dispatch(closeContextMenu({ index: fmIndex, value: undefined }));
};
document.addEventListener("pointerdown", onPointerDown, true);
return () => document.removeEventListener("pointerdown", onPointerDown, true);
}, [contextMenuOpen, dispatch, fmIndex]);
const showOpenWithCascading = displayOpt.showOpenWithCascading && displayOpt.showOpenWithCascading();
const showOpenWith = displayOpt.showOpenWith && displayOpt.showOpenWith();
let part1 =
@ -379,6 +396,14 @@ const ContextMenu = ({ fmIndex = 0 }: ContextMenuProps) => {
onClose={onClose}
disableAutoFocusItem
open={Boolean(contextMenuOpen)}
style={{ pointerEvents: "none" }}
slotProps={{
paper: {
style: {
pointerEvents: "auto",
},
},
}}
anchorReference="anchorPosition"
anchorPosition={{
top: contextMenuPos?.y ?? 0,

@ -1,4 +1,4 @@
import { DialogContent, Stack, useMediaQuery, useTheme } from "@mui/material";
import { DialogContent, FormControl, InputLabel, MenuItem, Select, Stack, useMediaQuery, useTheme } from "@mui/material";
import { useSnackbar } from "notistack";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@ -13,9 +13,19 @@ import { OutlineIconTextField } from "../../Common/Form/OutlineIconTextField.tsx
import { PathSelectorForm } from "../../Common/Form/PathSelectorForm.tsx";
import { ViewTaskAction } from "../../Common/Snackbar/snackbar.tsx";
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
import Edit from "../../Icons/Edit.tsx";
import Link from "../../Icons/Link.tsx";
import LockClosedKey from "../../Icons/LockClosedKey.tsx";
import PersonOutlined from "../../Icons/PersonOutlined.tsx";
import { FileManagerIndex } from "../FileManager.tsx";
const providerNames: Record<string, string> = {
aria2: "Aria2",
qbittorrent: "qBittorrent",
};
const providerDisplayName = (p: string) => providerNames[p] ?? p;
const CreateRemoteDownload = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
@ -26,7 +36,13 @@ const CreateRemoteDownload = () => {
const [loading, setLoading] = useState(false);
const [path, setPath] = useState("");
const [url, setUrl] = useState("");
const [fileName, setFileName] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [headers, setHeaders] = useState("");
const [provider, setProvider] = useState("");
const providers = useAppSelector((state) => state.siteConfig.explorer?.config?.remote_download_providers);
const open = useAppSelector((state) => state.globalState.remoteDownloadDialogOpen);
const target = useAppSelector((state) => state.globalState.remoteDownloadDialogFile);
const current = useAppSelector((state) => state.fileManager[FileManagerIndex.main].pure_path);
@ -37,6 +53,11 @@ const CreateRemoteDownload = () => {
const fs = initialPath.fs();
setPath(fs == Filesystem.shared_with_me || fs == Filesystem.trash ? defaultPath : initialPath.toString());
setUrl("");
setFileName("");
setUsername("");
setPassword("");
setHeaders("");
setProvider("");
}
}, [open]);
@ -55,6 +76,11 @@ const CreateRemoteDownload = () => {
src_file: target ? getFileLinkedUri(target) : undefined,
dst: path,
src: url ? url.split("\n") : undefined,
file_name: fileName || undefined,
username: username || undefined,
password: password || undefined,
headers: headers ? headers.split("\n").filter((h) => h.trim()) : undefined,
provider: provider || undefined,
}),
)
.then(() => {
@ -68,7 +94,7 @@ const CreateRemoteDownload = () => {
.finally(() => {
setLoading(false);
});
}, [target, url, path]);
}, [target, url, path, fileName, username, password, headers, provider]);
return (
<DraggableDialog
@ -111,6 +137,75 @@ const CreateRemoteDownload = () => {
label={t("modals.remoteDownloadDst")}
/>
</Stack>
<Stack spacing={3} direction={isMobile ? "column" : "row"}>
<OutlineIconTextField
icon={<Edit />}
variant="outlined"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
label={t("application:modals.remoteDownloadFileName")}
placeholder={t("modals.remoteDownloadFileNameDescription")}
fullWidth
/>
</Stack>
{providers && providers.length > 1 && (
<Stack spacing={3} direction={isMobile ? "column" : "row"}>
<FormControl variant="outlined" fullWidth>
<InputLabel>{t("application:modals.remoteDownloadProvider")}</InputLabel>
<Select
variant="outlined"
label={t("application:modals.remoteDownloadProvider")}
value={provider}
onChange={(e) => setProvider(e.target.value as string)}
>
<MenuItem value="">
<em>{t("application:modals.remoteDownloadProviderAuto")}</em>
</MenuItem>
{providers.map((p) => (
<MenuItem key={p} value={p}>
{providerDisplayName(p)}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
)}
{!target && (
<Stack spacing={3} direction={isMobile ? "column" : "row"}>
<OutlineIconTextField
icon={<PersonOutlined />}
variant="outlined"
value={username}
onChange={(e) => setUsername(e.target.value)}
label={t("application:modals.remoteDownloadHttpUser")}
fullWidth
/>
<OutlineIconTextField
icon={<LockClosedKey />}
variant="outlined"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
label={t("application:modals.remoteDownloadHttpPassword")}
fullWidth
/>
</Stack>
)}
{!target && (
<Stack spacing={3} direction={isMobile ? "column" : "row"}>
<OutlineIconTextField
icon={<Link />}
variant="outlined"
value={headers}
multiline
minRows={2}
onChange={(e) => setHeaders(e.target.value)}
placeholder={t("modals.remoteDownloadHeadersDescription")}
label={t("application:modals.remoteDownloadHeaders")}
fullWidth
/>
</Stack>
)}
</Stack>
</DialogContent>
</DraggableDialog>

@ -22,6 +22,7 @@ import VideoViewer from "../../Viewers/Video/VideoViewer.tsx";
import PdfViewer from "../../Viewers/PdfViewer.tsx";
import CustomViewer from "../../Viewers/CustomViewer.tsx";
import EpubViewer from "../../Viewers/EpubViewer/EpubViewer.tsx";
import ModelViewer from "../../Viewers/ModelViewer/ModelViewer.tsx";
import ExcalidrawViewer from "../../Viewers/Excalidraw/ExcalidrawViewer.tsx";
import CreateNew from "./CreateNew.tsx";
import { useAppSelector } from "../../../redux/hooks.ts";
@ -71,6 +72,7 @@ const Dialogs = () => {
<PdfViewer />
<CustomViewer />
<EpubViewer />
<ModelViewer />
{showCreateArchive != undefined && <CreateArchive />}
{showExtractArchive != undefined && <ExtractArchive />}
{showRemoteDownload != undefined && <CreateRemoteDownload />}

@ -13,6 +13,7 @@ import {
} from "@mui/material";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { sendUpdateUserSetting } from "../../../api/api.ts";
import { Viewer, ViewerType } from "../../../api/explorer.ts";
import { closeViewerSelector } from "../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
@ -129,7 +130,15 @@ const OpenWith = () => {
}
if (always) {
SessionManager.set(UserSettings.OpenWithPrefix + ext, viewer?.id ?? selectedViewer?.id);
const viewerId = viewer?.id ?? selectedViewer?.id;
SessionManager.set(UserSettings.OpenWithPrefix + ext, viewerId);
const user = SessionManager.currentUser();
if (user && viewerId) {
const merged = { ...(user.preferred_viewers ?? {}), [ext]: viewerId };
dispatch(sendUpdateUserSetting({ preferred_viewers: merged }))
.then(() => SessionManager.updateUserIfExist({ ...user, preferred_viewers: merged }))
.catch(() => {});
}
}
dispatch(

@ -8,6 +8,7 @@ import { Share as ShareModel } from "../../../../api/explorer.ts";
import { closeShareLinkDialog } from "../../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import { createOrUpdateShareLink } from "../../../../redux/thunks/share.ts";
import SessionManager from "../../../../session";
import { copyToClipboard, sendLink } from "../../../../util";
import AutoHeight from "../../../Common/AutoHeight.tsx";
import { FilledTextField, SmallFormControlLabel } from "../../../Common/StyledComponents.tsx";
@ -17,9 +18,35 @@ import Share from "../../../Icons/Share.tsx";
import { FileManagerIndex } from "../../FileManager.tsx";
import ShareSettingContent, { downloadOptions, expireOptions, ShareSetting } from "./ShareSetting.tsx";
const initialSetting: ShareSetting = {
const shareSettingStorageKey = "cloudreve.share_setting";
const initialSetting = (privateByDefault: boolean): ShareSetting => ({
is_private: privateByDefault || undefined,
expires_val: expireOptions[2],
downloads_val: downloadOptions[0],
});
// lastUsedSetting hydrates the dialog from the previously saved share
// settings (#2519). Password fields are never persisted.
const lastUsedSetting = (privateByDefault: boolean): ShareSetting => {
try {
const raw = localStorage.getItem(shareSettingStorageKey);
if (raw) {
return { ...initialSetting(privateByDefault), ...JSON.parse(raw) };
}
} catch {
// corrupted or unavailable storage falls back to defaults
}
return initialSetting(privateByDefault);
};
const rememberSetting = (setting: ShareSetting) => {
try {
const { password, use_custom_password, ...rest } = setting;
localStorage.setItem(shareSettingStorageKey, JSON.stringify(rest));
} catch {
// storage unavailable; remembering is best-effort
}
};
interface ShareLinkPassword {
@ -38,6 +65,7 @@ const shareToSetting = (share: ShareModel, t: TFunction): ShareSetting => {
allow_edit: share.allow_edit,
preview_only: share.preview_only,
upload_only: share.upload_only,
note: share.note,
downloads: share.remain_downloads != undefined && share.remain_downloads > 0,
expires_val: expireOptions[2],
@ -74,8 +102,12 @@ const ShareDialog = () => {
const dispatch = useAppDispatch();
const theme = useTheme();
const sitePrivateDefault = useAppSelector((state) => state.siteConfig.basic.config.share_default_private);
const userPrivateDefault = SessionManager.currentLoginOrNull()?.user.share_default_private;
const privateByDefault = userPrivateDefault ?? !!sitePrivateDefault;
const [loading, setLoading] = useState(false);
const [setting, setSetting] = useState<ShareSetting>(initialSetting);
const [setting, setSetting] = useState<ShareSetting>(() => initialSetting(privateByDefault));
const [shareLink, setShareLink] = useState<string>("");
const [includePassword, setIncludePassword] = useState(true);
const shareLinkPassword = useMemo(() => {
@ -97,7 +129,7 @@ const ShareDialog = () => {
if (editTarget) {
setSetting(shareToSetting(editTarget, t));
} else {
setSetting(initialSetting);
setSetting(lastUsedSetting(privateByDefault));
}
setShareLink("");
setIncludePassword(true);
@ -128,6 +160,7 @@ const ShareDialog = () => {
const shareLink = await dispatch(
createOrUpdateShareLink(FileManagerIndex.main, target, setting, editTarget?.id),
);
rememberSetting(setting);
setShareLink(shareLink);
} catch (e) {
} finally {

@ -28,6 +28,7 @@ import Edit from "../../../Icons/Edit.tsx";
import Eye from "../../../Icons/Eye.tsx";
import EyeOff from "../../../Icons/EyeOff.tsx";
import FolderAdd from "../../../Icons/FolderAdd.tsx";
import RenameOutlined from "../../../Icons/RenameOutlined.tsx";
import TableSettingsOutlined from "../../../Icons/TableSettings.tsx";
import Timer from "../../../Icons/Timer.tsx";
import Upload from "../../../Icons/Upload.tsx";
@ -87,6 +88,7 @@ export interface ShareSetting {
allow_edit?: boolean;
preview_only?: boolean;
upload_only?: boolean;
note?: string;
downloads?: boolean;
expires?: boolean;
@ -210,6 +212,37 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS
)}
</AccordionDetails>
</Accordion>
<Accordion expanded={expanded === "note"} onChange={handleExpand("note")}>
<AccordionSummary aria-controls="panel-note-content" id="panel-note-header">
<StyledListItemButton>
<ListItemIcon>
<RenameOutlined />
</ListItemIcon>
<ListItemText
primary={t("application:modals.shareNote")}
secondary={setting.note || undefined}
secondaryTypographyProps={{ noWrap: true }}
/>
</StyledListItemButton>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" sx={{ mb: 1 }}>
{t("application:modals.shareNoteDes")}
</Typography>
<FormControl variant="standard" fullWidth>
<FilledTextField
label={t("application:modals.shareNote")}
slotProps={{
htmlInput: {
maxLength: 255,
},
}}
value={setting.note ?? ""}
onChange={(e) => onSettingChange({ ...setting, note: e.target.value })}
/>
</FormControl>
</AccordionDetails>
</Accordion>
<Accordion expanded={expanded === "preview_only"} onChange={handleExpand("preview_only")}>
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
<StyledListItemButton>

@ -173,6 +173,16 @@ const GalleryImage = memo((props: FileBlockProps) => {
</Fade>
)}
</TransitionGroup>
{file.type == FileType.folder && !isLoadingIndicator && !file.placeholder && (
<ImageListItemBar
title={file.name}
position="bottom"
sx={{
background: "linear-gradient(to top, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.25) 60%, rgba(0,0,0,0) 100%)",
"& .MuiImageListItemBar-titleWrap": { px: 1 },
}}
/>
)}
<Fade in={!isLoadingIndicator && (hovered || !!isSelected)}>
<ImageListItemBar
sx={{

@ -1,4 +1,4 @@
import { Box, Fade, Grow, InputBase, PopoverProps, Tooltip, Typography, useMediaQuery, useTheme } from "@mui/material";
import { Box, Fade, Grow, IconButton, InputBase, PopoverProps, Tooltip, Typography, useMediaQuery, useTheme } from "@mui/material";
import React, { memo, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { sizeToString } from "../../../../util";
import CrUri, { SearchParam } from "../../../../util/uri.ts";
@ -21,6 +21,7 @@ import { loadFileThumb, patchCustomProp } from "../../../../redux/thunks/file.ts
import AutoHeight from "../../../Common/AutoHeight.tsx";
import { NoWrapBox } from "../../../Common/StyledComponents.tsx";
import TimeBadge from "../../../Common/TimeBadge.tsx";
import Dismiss from "../../../Icons/Dismiss.tsx";
import Info from "../../../Icons/Info.tsx";
import { DisplayOption } from "../../ContextMenu/useActionDisplayOpt.ts";
import FileBadge from "../../FileBadge.tsx";
@ -392,6 +393,7 @@ const CustomPropsCell = memo(({ file, customProp, readOnly }: CustomPropsCellPro
const dispatch = useAppDispatch();
const fmIndex = useContext(FmIndexContext);
const [loading, setLoading] = useState(false);
const [mouseOver, setMouseOver] = useState(false);
const stopPropagation = useCallback((e: React.SyntheticEvent) => {
e.stopPropagation();
@ -407,6 +409,18 @@ const CustomPropsCell = memo(({ file, customProp, readOnly }: CustomPropsCellPro
[dispatch, fmIndex, file, customProp.id],
);
const onDelete = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (loading || readOnly) return;
setLoading(true);
dispatch(patchCustomProp(fmIndex, file, customProp.id, "", true)).finally(() => {
setLoading(false);
});
},
[dispatch, fmIndex, file, customProp.id, loading, readOnly],
);
return (
<Box
onClick={stopPropagation}
@ -414,9 +428,18 @@ const CustomPropsCell = memo(({ file, customProp, readOnly }: CustomPropsCellPro
onMouseDown={stopPropagation}
onMouseMove={stopPropagation}
onDragStart={stopPropagation}
sx={{ width: "100%" }}
onMouseEnter={() => setMouseOver(true)}
onMouseLeave={() => setMouseOver(false)}
sx={{ width: "100%", display: "flex", alignItems: "center" }}
>
{getPropsContent(customProp, onChange, loading, readOnly)}
<Box sx={{ flex: 1, minWidth: 0 }}>{getPropsContent(customProp, onChange, loading, readOnly)}</Box>
{!readOnly && (
<Grow in={mouseOver} unmountOnExit>
<IconButton size="small" onClick={onDelete} disabled={loading}>
<Dismiss fontSize="small" />
</IconButton>
</Grow>
)}
</Box>
);
});

@ -1,6 +1,8 @@
import { useContext, useMemo } from "react";
import { Box } from "@mui/material";
import { useCallback, useContext, useMemo } from "react";
import { Virtuoso } from "react-virtuoso";
import { useAppSelector } from "../../../../redux/hooks.ts";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import { setSelected } from "../../../../redux/fileManagerSlice.ts";
import DndWrappedFile from "../../Dnd/DndWrappedFile.tsx";
import { FmIndexContext } from "../../FmIndexContext.tsx";
import { FmFile, loadingPlaceHolderNumb } from "../GridView/GridView.tsx";
@ -14,6 +16,7 @@ export interface ListBodyProps {
const ListBody = ({ columns }: ListBodyProps) => {
const fmIndex = useContext(FmIndexContext);
const dispatch = useAppDispatch();
const files = useAppSelector((state) => state.fileManager[fmIndex].list?.files);
const mixedType = useAppSelector((state) => state.fileManager[fmIndex].list?.mixed_type);
const pagination = useAppSelector((state) => state.fileManager[fmIndex].list?.pagination);
@ -46,25 +49,38 @@ const ListBody = ({ columns }: ListBodyProps) => {
return list;
}, [files, mixedType, pagination, search_params]);
// Clicking empty space below/around rows clears the selection (#3223);
// row clicks are excluded via the data-fm-row marker.
const onBackgroundClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
if (!(e.target as HTMLElement).closest("[data-fm-row]")) {
dispatch(setSelected({ index: fmIndex, value: [] }));
}
},
[dispatch, fmIndex],
);
return (
<Virtuoso
style={{
height: "100%",
}}
increaseViewportBy={180}
data={list}
itemContent={(index, file) => (
<DndWrappedFile
columns={columns}
key={file.id}
component={Row}
search={search_params}
index={index}
showThumb={showThumb}
file={file}
/>
)}
/>
<Box sx={{ height: "100%" }} onClick={onBackgroundClick}>
<Virtuoso
style={{
height: "100%",
}}
increaseViewportBy={180}
data={list}
itemContent={(index, file) => (
<DndWrappedFile
columns={columns}
key={file.id}
component={Row}
search={search_params}
index={index}
showThumb={showThumb}
file={file}
/>
)}
/>
</Box>
);
};

@ -91,6 +91,7 @@ const Row = memo((props: FileBlockProps) => {
return (
<RowContainer
data-fm-row
transparent={isDragging || fileDisabled}
isDropOver={isDropOver && !isDragging}
ref={setRefFunc}

@ -5,6 +5,8 @@ import { TransitionGroup } from "react-transition-group";
import { sendMetadataPatch } from "../../../../api/api.ts";
import { CustomProps as CustomPropsType, FileResponse } from "../../../../api/explorer.ts";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import { fileUpdated } from "../../../../redux/fileManagerSlice.ts";
import { FileManagerIndex } from "../../FileManager.tsx";
import { DisplayOption } from "../../ContextMenu/useActionDisplayOpt.ts";
import AddButton from "./AddButton.tsx";
import CustomPropsCard from "./CustomPropsItem.tsx";
@ -28,22 +30,28 @@ const CustomProps = ({ file, setTarget, targetDisplayOptions }: CustomPropsProps
const [loading, setLoading] = useState(false);
const dispatch = useAppDispatch();
const custom_props = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props);
// `file` is a snapshot taken when the sidebar opened; prefer live metadata
// from the file list so props patched from list view appear immediately.
const liveMetadata = useAppSelector(
(state) => state.fileManager[FileManagerIndex.main]?.list?.files.find((f) => f.path === file.path)?.metadata,
);
const metadata = liveMetadata ?? file.metadata;
const existingProps = useMemo(() => {
if (!file.metadata) {
if (!metadata) {
return [];
}
return Object.keys(file.metadata)
return Object.keys(metadata)
.filter((key) => key.startsWith(customPropsMetadataPrefix))
.map((key) => {
const propId = key.slice(customPropsMetadataPrefix.length);
return {
id: propId,
props: custom_props?.find((prop) => prop.id === propId),
value: file.metadata?.[key] ?? "",
value: metadata?.[key] ?? "",
} as CustomPropsItem;
});
}, [file.metadata]);
}, [metadata, custom_props]);
const existingPropIds = useMemo(() => {
return existingProps?.map((prop) => prop.id) ?? [];
@ -62,21 +70,22 @@ const CustomProps = ({ file, setTarget, targetDisplayOptions }: CustomPropsProps
}),
)
.then(() => {
if (remove) {
const newMetadata = { ...file.metadata };
props.forEach((prop) => {
const newMetadata = { ...metadata };
props.forEach((prop) => {
if (remove) {
delete newMetadata[customPropsMetadataPrefix + prop.id];
});
setTarget({ ...file, metadata: newMetadata });
} else {
setTarget({
...file,
metadata: {
...file.metadata,
...Object.assign({}, ...props.map((prop) => ({ [customPropsMetadataPrefix + prop.id]: prop.value }))),
},
});
}
} else {
newMetadata[customPropsMetadataPrefix + prop.id] = prop.value;
}
});
const updated = { ...file, metadata: newMetadata };
setTarget(updated);
dispatch(
fileUpdated({
index: FileManagerIndex.main,
value: [{ file: updated, oldPath: file.path, includeMetadata: true }],
}),
);
})
.finally(() => {
setLoading(false);

@ -50,6 +50,7 @@ const Details = ({ target, inPhotoViewer, setTarget, targetDisplayOptions }: Det
setThumbSrc(null);
}}
src={thumbSrc}
draggable={false}
sx={{
borderRadius: "8px",
}}

@ -69,7 +69,7 @@ const Breadcrumb = (props: BreadcrumbProps) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const hiddenOpen = Boolean(anchorEl);
const hiddenExpandButtonRef = useRef<HTMLButtonElement>();
const chainRef = useRef(null);
const chainRef = useRef<HTMLElement>(null);
const openHiddenMenu = (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
@ -129,16 +129,39 @@ const Breadcrumb = (props: BreadcrumbProps) => {
const isOverflow = useIsOverflow(chainRef, (_isOverflow) => {});
// Width needed to render the full chain, captured before collapsing.
const requiredWidthRef = useRef(0);
useEffect(() => {
if (isOverflow && !isMobile) {
requiredWidthRef.current = chainRef.current?.scrollWidth ?? requiredWidthRef.current;
setMaxHiding(buttons.length - 1);
}
}, [isOverflow, isMobile]);
// Restore the full chain once the container can fit it again. A dedicated
// observer is needed: isOverflow stays false once the collapsed chain fits,
// so it cannot drive the restore itself (#3288).
useEffect(() => {
const el = chainRef.current;
if (!el || props.displayOnly) {
return;
}
const observer = new ResizeObserver(() => {
if (requiredWidthRef.current > 0 && el.clientWidth >= requiredWidthRef.current) {
requiredWidthRef.current = 0;
setMaxHiding(0);
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// Cancel collapse when elements are less than previous
useEffect(() => {
const current = elements?.length ?? 0;
if (previousElements.current > current) {
requiredWidthRef.current = 0;
setTimeout(() => {
setMaxHiding(0);
}, theme.transitions.duration.standard);

@ -4,15 +4,19 @@ import { useCallback, useContext } from "react";
import { useTranslation } from "react-i18next";
import { clearSelected } from "../../../redux/fileManagerSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { NavigatorCapability } from "../../../api/explorer.ts";
import { downloadAll } from "../../../redux/thunks/download.ts";
import { createShareShortcut, isMacbook } from "../../../redux/thunks/file.ts";
import { inverseSelection, pinCurrentView, refreshFileList, selectAll } from "../../../redux/thunks/filemanager.ts";
import SessionManager from "../../../session";
import Boolset from "../../../util/boolset.ts";
import { Filesystem } from "../../../util/uri.ts";
import { KeyIndicator } from "../../Frame/NavBar/SearchBar.tsx";
import ArrowSync from "../../Icons/ArrowSync.tsx";
import Border from "../../Icons/Border.tsx";
import BorderAll from "../../Icons/BorderAll.tsx";
import BorderInside from "../../Icons/BorderInside.tsx";
import CloudDownloadOutlined from "../../Icons/CloudDownloadOutlined.tsx";
import FolderLink from "../../Icons/FolderLink.tsx";
import PinOutlined from "../../Icons/PinOutlined.tsx";
import { DenseDivider, SquareMenu, SquareMenuItem } from "../ContextMenu/ContextMenu.tsx";
@ -22,6 +26,8 @@ const MoreActionMenu = ({ onClose, ...rest }: MenuProps) => {
const { t } = useTranslation();
const fmIndex = useContext(FmIndexContext);
const fs = useAppSelector((state) => state.fileManager[fmIndex].current_fs);
const parentCapability = useAppSelector((state) => state.fileManager[fmIndex].list?.parent?.capability);
const canDownloadAll = new Boolset(parentCapability).enabled(NavigatorCapability.download_file);
const dispatch = useAppDispatch();
const isLogin = !!SessionManager.currentLoginOrNull();
const theme = useTheme();
@ -62,6 +68,11 @@ const MoreActionMenu = ({ onClose, ...rest }: MenuProps) => {
dispatch(refreshFileList(fmIndex));
}, [dispatch, onClose, fmIndex]);
const onDownloadAllClicked = useCallback(() => {
onClose && onClose({}, "escapeKeyDown");
dispatch(downloadAll(fmIndex));
}, [dispatch, onClose, fmIndex]);
return (
<SquareMenu
MenuListProps={{
@ -104,6 +115,14 @@ const MoreActionMenu = ({ onClose, ...rest }: MenuProps) => {
<ListItemText>{t("application:fileManager.saveShortcut")}</ListItemText>
</SquareMenuItem>
)}
{fs == Filesystem.share && canDownloadAll && (
<SquareMenuItem onClick={onDownloadAllClicked}>
<ListItemIcon>
<CloudDownloadOutlined fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.downloadAll")}</ListItemText>
</SquareMenuItem>
)}
{isLogin && <DenseDivider />}
<SquareMenuItem onClick={onSelectAllClicked}>
<ListItemIcon>

@ -131,7 +131,7 @@ const TreeFiles = React.memo(
))
: shadowChild}
</TreeFile>
{limit < childTreeFiles.length ? (
{!flatten && limit < childTreeFiles.length ? (
<SideNavItem level={level + 1} label={t("navbar.showMore")} onClick={() => setLimit((l) => l + 50)} />
) : null}
</>

@ -4,7 +4,7 @@ import SvgIcon from "@mui/material/SvgIcon/SvgIcon";
import { memo, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { GroupPermission } from "../../../api/user.ts";
import { GroupPermission, isAnyAdmin } from "../../../api/user.ts";
import { useAppSelector } from "../../../redux/hooks.ts";
import SessionManager from "../../../session";
import { GroupBS } from "../../../session/utils.ts";
@ -52,6 +52,9 @@ export interface NavigationItem {
icon?: ((props: SvgIconProps) => JSX.Element)[] | (typeof SvgIcon)[];
iconifyName?: string;
path: string;
// Delegated admin section bit required to see this item. Full admins
// (is_admin) always pass; undefined means any admin.
permission?: number;
}
let NavigationItems: NavigationItem[];
@ -137,80 +140,100 @@ AdminNavigationItems = [
label: "dashboard:nav.settings",
icon: [Setting, SettingsOutlined],
path: "/admin/settings",
permission: GroupPermission.admin_settings,
},
{
label: "dashboard:nav.fileSystem",
icon: [CubeTreeFilled, CubeTree],
path: "/admin/filesystem",
permission: GroupPermission.admin_settings,
},
{
label: "dashboard:nav.storagePolicy",
icon: [Storage, StorageOutlined],
path: "/admin/policy",
permission: GroupPermission.admin_storage,
},
{
label: "dashboard:nav.nodes",
icon: [ServerFilled, Server],
path: "/admin/node",
permission: GroupPermission.admin_storage,
},
{
label: "dashboard:nav.groups",
icon: [PeopleFilled, People],
path: "/admin/group",
permission: GroupPermission.admin_groups,
},
{
label: "dashboard:nav.users",
icon: [Person, PersonOutlined],
path: "/admin/user",
permission: GroupPermission.admin_users,
},
{
label: "dashboard:nav.files",
icon: [Folder, FolderOutlined],
path: "/admin/file",
permission: GroupPermission.admin_files,
},
{
label: "dashboard:nav.entities",
icon: [BoxMultipleFilled, BoxMultiple],
path: "/admin/blob",
permission: GroupPermission.admin_files,
},
{
label: "dashboard:nav.shares",
icon: [ShareAndroid, ShareOutlined],
path: "/admin/share",
permission: GroupPermission.admin_shares,
},
{
label: "dashboard:nav.tasks",
icon: [CubeSyncFilled, CubeSync],
path: "/admin/task",
permission: GroupPermission.admin_queue,
},
{
label: "dashboard:vas.orders",
icon: [PaymentFilled, Payment],
path: "/admin/payment",
permission: GroupPermission.admin_payment,
},
{
label: "dashboard:nav.events",
icon: [SendLoggingFilled, SendLogging],
path: "/admin/event",
permission: GroupPermission.admin_events,
},
{
label: "dashboard:nav.abuseReport",
icon: [Warning, WarningOutlined],
path: "/admin/abuse",
permission: GroupPermission.admin_reports,
},
{
label: "dashboard:nav.oauthClients",
icon: [ShieldLockFilled, ShieldLock],
path: "/admin/oauth",
permission: GroupPermission.admin_settings,
},
];
export const AdminPageNavigation = memo(() => {
const user = SessionManager.currentLoginOrNull();
const permission = useMemo(() => GroupBS(user?.user), [user?.user?.group?.permission]);
const isFullAdmin = permission.enabled(GroupPermission.is_admin);
const visibleItems = AdminNavigationItems.filter(
(item) => item.permission === undefined || isFullAdmin || permission.enabled(item.permission),
);
return (
<>
<SideNavItemComponent key={AdminNavigationItems[0].label} item={AdminNavigationItems[0]} />
<SideNavItemComponent key={visibleItems[0].label} item={visibleItems[0]} />
<Box>
{AdminNavigationItems.slice(1).map((item) => (
{visibleItems.slice(1).map((item) => (
<SideNavItemComponent key={item.label} item={item} />
))}
</Box>
@ -229,7 +252,7 @@ const PageNavigation = () => {
const appPromotionEnabled = useAppSelector((state) => state.siteConfig.basic.config.app_promotion);
const user = SessionManager.currentLoginOrNull();
const isAdmin = useMemo(() => {
return GroupBS(user?.user).enabled(GroupPermission.is_admin);
return isAnyAdmin(GroupBS(user?.user));
}, [user?.user?.group?.permission]);
const remoteDownloadEnabled = useMemo(() => {
return GroupBS(user?.user).enabled(GroupPermission.remote_download);
@ -256,16 +279,18 @@ const PageNavigation = () => {
)}
{customNavItems && customNavItems.length > 0 && (
<Box>
{customNavItems.map((item) => (
<SideNavItemComponent
key={item.name}
item={{
label: item.name,
iconifyName: item.icon,
path: item.url,
}}
/>
))}
{customNavItems
.filter((item) => isLogin || item.scope !== "user")
.map((item) => (
<SideNavItemComponent
key={item.name}
item={{
label: item.name,
iconifyName: item.icon,
path: item.url,
}}
/>
))}
</Box>
)}
{isLogin && isAdmin && (

@ -17,7 +17,7 @@ import { bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { GroupPermission } from "../../../api/user.ts";
import { isAnyAdmin } from "../../../api/user.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { signout } from "../../../redux/thunks/session.ts";
import SessionManager, { Session } from "../../../session";
@ -47,7 +47,7 @@ const UserPopover = ({ open, onClose, ...rest }: PopoverProps) => {
}
const isAdmin = useMemo(() => {
return GroupBS(user).enabled(GroupPermission.is_admin);
return isAnyAdmin(GroupBS(user));
}, [user.group?.permission]);
const signWithHint = (email: string) => {

@ -0,0 +1,82 @@
import { useTranslation } from "react-i18next";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { useNavigate } from "react-router-dom";
import { useQuery } from "../../../util";
import React, { useEffect, useState } from "react";
import { Box, Button, Typography } from "@mui/material";
import PageTitle from "../../../router/PageTitle.tsx";
import CheckmarkCircle from "../../Icons/CheckmarkCircle.tsx";
import { setHeadlessFrameLoading } from "../../../redux/globalStateSlice.ts";
import { sendEmailChangeActivate } from "../../../api/api.ts";
const ActivateEmailChange = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const query = useQuery();
const [success, setSuccess] = useState(true);
useEffect(() => {
const sign = query.get("sign");
const id = query.get("id");
if (!sign || !id) {
setSuccess(false);
navigate("/session");
return;
}
dispatch(setHeadlessFrameLoading(true));
dispatch(sendEmailChangeActivate(id, decodeURIComponent(sign)))
.then(() => {
setSuccess(true);
})
.catch(() => {
navigate("/session");
})
.finally(() => {
dispatch(setHeadlessFrameLoading(false));
});
}, []);
return (
<Box>
<PageTitle title={t("login.emailChangedTitle")} />
<Box sx={{ overflow: "hidden" }}>
{success && (
<>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
py: 7,
}}
>
<CheckmarkCircle fontSize={"large"} color={"success"} />
<Typography
variant={"h6"}
sx={{
color: (theme) => theme.palette.success.main,
mt: 1,
}}
>
{t("application:login.emailChanged")}
</Typography>
</Box>
<Button
onClick={() => navigate("/session")}
sx={{ mt: 2 }}
variant="contained"
color="primary"
>
{t("login.backToSingIn")}
</Button>
</>
)}
</Box>
</Box>
);
};
export default ActivateEmailChange;

@ -14,7 +14,7 @@ import {
sendPrepareLogin,
sendResetEmail,
} from "../../../../api/api.ts";
import { AppError, Code } from "../../../../api/request.ts";
import { ApiPrefix, AppError, Code } from "../../../../api/request.ts";
import { AppRegistration, GrantService, LoginResponse, PrepareLoginResponse } from "../../../../api/user.ts";
import { clearOAuthApp, setOAuthApp, setOAuthAppLoading } from "../../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
@ -86,6 +86,7 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => {
// Get OAuth app from Redux
const app = useAppSelector((state) => state.globalState.oauthApp);
const { sso_enabled, sso_auto_redirect } = useAppSelector((state) => state.siteConfig.login.config);
const [phase, setPhase] = useState<EmailLoginPhase>(EmailLoginPhase.CollectEmail);
const [email, setEmail] = useState("");
@ -312,6 +313,18 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => {
});
}
// Auto-redirect to the configured SSO provider. `nosso` and an existing
// sso_error keep the password form reachable for admin recovery.
if (sso_enabled && sso_auto_redirect && !ssoError && !query.get("nosso") && !isOAuthFlow) {
const target = new URL(ApiPrefix + "/session/sso", window.location.origin);
const redirect = query.get("redirect");
if (redirect) {
target.searchParams.set("redirect", redirect);
}
window.location.replace(target.toString());
return;
}
const init = async () => {
if (isOAuthFlow) {
const registration = await loadAppRegistration();

@ -0,0 +1,80 @@
import { useTranslation } from "react-i18next";
import { useSnackbar } from "notistack";
import { useAppDispatch } from "../../../redux/hooks.ts";
import React, { useState } from "react";
import { DialogContent, FormControl, FormHelperText, Typography } from "@mui/material";
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
import { DenseFilledTextField } from "../../Common/StyledComponents.tsx";
import { sendRequestEmailChange } from "../../../api/api.ts";
export interface ChangeEmailDialogProps {
open?: boolean;
onClose: () => void;
currentEmail?: string;
}
const ChangeEmailDialog = ({ open, onClose, currentEmail }: ChangeEmailDialogProps) => {
const { t } = useTranslation();
const { enqueueSnackbar } = useSnackbar();
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const submit = () => {
setLoading(true);
dispatch(sendRequestEmailChange(email, password))
.then(() => {
enqueueSnackbar({ message: t("setting.emailChangeSent"), variant: "success" });
setEmail("");
setPassword("");
onClose();
})
.finally(() => setLoading(false));
};
return (
<DraggableDialog
title={t("setting.changeEmail")}
showCancel
onAccept={submit}
loading={loading}
dialogProps={{
open: !!open,
onClose: onClose,
fullWidth: true,
maxWidth: "xs",
}}
>
<DialogContent>
<Typography variant="body2" sx={{ mb: 2 }}>
{t("setting.changeEmailDes", { email: currentEmail })}
</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<DenseFilledTextField
required
type="email"
label={t("setting.newEmail")}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
</FormControl>
<FormControl fullWidth>
<DenseFilledTextField
required
type="password"
label={t("login.password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
<FormHelperText>{t("setting.changeEmailPasswordDes")}</FormHelperText>
</FormControl>
</DialogContent>
</DraggableDialog>
);
};
export default ChangeEmailDialog;

@ -23,6 +23,7 @@ import { UserSettings as UserSettingsType } from "../../../api/user.ts";
import { languages } from "../../../i18n.ts";
import { setPreferredTheme } from "../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { ViewersByID } from "../../../redux/siteConfigSlice.ts";
import { clearLocalCustomView } from "../../../redux/thunks/filemanager.ts";
import { selectLanguage } from "../../../redux/thunks/settings.ts";
import SessionManager, { UserSettings } from "../../../session";
@ -85,6 +86,29 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => {
const [folderClickAction, setFolderClickAction] = useState(
SessionManager.getWithFallback(UserSettings.FolderClickAction),
);
const [trashRetentionDays, setTrashRetentionDays] = useState(
Math.round((setting.trash_retention ?? 0) / 86400),
);
const onTrashRetentionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const days = Math.max(0, parseInt(e.target.value) || 0);
setTrashRetentionDays(days);
};
const saveTrashRetention = () => {
const seconds = trashRetentionDays * 86400;
if (seconds == (setting.trash_retention ?? 0)) {
return;
}
setLoading(true);
dispatch(sendUpdateUserSetting({ trash_retention: seconds }))
.then(() => {
setSetting({ ...setting, trash_retention: seconds });
})
.finally(() => {
setLoading(false);
});
};
const onRetentionCheckChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setVersionRetentionEnabled(e.target.checked);
@ -106,6 +130,7 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => {
setVersionRetentionEnabled(setting.version_retention_enabled);
setVersionRetentionMax(setting.version_retention_max);
setVersionRetentionExts(setting.version_retention_ext);
setTrashRetentionDays(Math.round((setting.trash_retention ?? 0) / 86400));
}, [setting]);
const selectTimeZone = (value: string) => {
@ -190,6 +215,27 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => {
}
};
const preferredViewerEntries = useMemo(
() => Object.entries(setting.preferred_viewers ?? {}),
[setting.preferred_viewers],
);
const removePreferredViewer = (ext: string) => {
const next = { ...(setting.preferred_viewers ?? {}) };
delete next[ext];
setSetting({ ...setting, preferred_viewers: next });
setLoading(true);
dispatch(sendUpdateUserSetting({ preferred_viewers: next }))
.then(() => {
const session = SessionManager.currentLoginOrNull();
if (session?.user) {
SessionManager.updateUserIfExist({ ...session.user, preferred_viewers: next });
}
SessionManager.set(UserSettings.OpenWithPrefix + ext, undefined);
})
.finally(() => setLoading(false));
};
return (
<Stack spacing={3}>
<SettingForm title={t("setting.language")} lgWidth={3}>
@ -233,14 +279,16 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => {
</SettingForm>
<SettingForm title={t("setting.themeColor")} lgWidth={12}>
<SelectorBox sx={{ gap: 1 }}>
{Object.keys(themeOptions).map((color, index) => (
<ColorCircle
size={30}
color={color}
onClick={() => applyTheme(color)}
selected={(preferredTheme && preferredTheme == color) || (!preferredTheme && defaultTheme == color)}
/>
))}
{Object.keys(themeOptions)
.filter((color) => !themeOptions[color]?.hidden)
.map((color, index) => (
<ColorCircle
size={30}
color={color}
onClick={() => applyTheme(color)}
selected={(preferredTheme && preferredTheme == color) || (!preferredTheme && defaultTheme == color)}
/>
))}
</SelectorBox>
</SettingForm>
<SettingForm title={t("setting.versionRetention")}>
@ -325,6 +373,16 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => {
</Stack>
</OutlinedSettingBox>
</SettingForm>
<SettingForm title={t("setting.trashRetention")} lgWidth={12}>
<DenseFilledTextField
type="number"
value={trashRetentionDays}
onChange={onTrashRetentionChange}
onBlur={saveTrashRetention}
inputProps={{ min: 0, step: 1 }}
helperText={t("setting.trashRetentionDes")}
/>
</SettingForm>
<SettingForm title={t("setting.syncView")} lgWidth={12}>
<ToggleButtonGroup
color="primary"
@ -373,6 +431,25 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => {
</ToggleButtonGroup>
<FormHelperText>{t("setting.folderClickActionDes")}</FormHelperText>
</SettingForm>
<SettingForm title={t("setting.preferredViewers")} lgWidth={12}>
<Box>
{preferredViewerEntries.length == 0 && (
<FormHelperText>{t("setting.preferredViewersEmpty")}</FormHelperText>
)}
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", gap: 1 }}>
{preferredViewerEntries.map(([ext, viewerId]) => (
<Chip
key={ext}
size="small"
label={`.${ext} → ${ViewersByID[viewerId] ? t(ViewersByID[viewerId].display_name) : viewerId}`}
onDelete={() => removePreferredViewer(ext)}
disabled={loading}
/>
))}
</Stack>
<FormHelperText>{t("setting.preferredViewersDes")}</FormHelperText>
</Box>
</SettingForm>
</Stack>
);
};

@ -1,5 +1,5 @@
import { LoadingButton } from "@mui/lab";
import { Collapse, Grid2, Stack, Typography, useMediaQuery, useTheme, styled } from "@mui/material";
import { Button, Collapse, FormHelperText, Grid2, ListItemText, Stack, Typography, useMediaQuery, useTheme, styled } from "@mui/material";
import { bindPopover, bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@ -7,10 +7,12 @@ import { sendUpdateUserSetting } from "../../../api/api.ts";
import { UserSettings, ShareLinksInProfileLevel } from "../../../api/user.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import SessionManager from "../../../session";
import { DefaultButton, DenseFilledTextField } from "../../Common/StyledComponents.tsx";
import { DefaultButton, DenseFilledTextField, DenseSelect } from "../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
import TimeBadge from "../../Common/TimeBadge.tsx";
import CaretDown from "../../Icons/CaretDown.tsx";
import AvatarSetting from "./AvatarSetting.tsx";
import ChangeEmailDialog from "./ChangeEmailDialog.tsx";
import ProfileSettingPopover, { useProfileSettingSummary } from "./ProfileSettingPopover.tsx";
import SettingForm from "./SettingForm.tsx";
@ -42,6 +44,7 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => {
const [nick, setNick] = useState(user?.user.nickname);
const [nickLoading, setNickLoading] = useState(false);
const [profileSettingLoading, setProfileSettingLoading] = useState(false);
const [emailDialogOpen, setEmailDialogOpen] = useState(false);
const profileSettingPopup = usePopupState({
variant: "popover",
@ -101,7 +104,12 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => {
<Grid2 spacing={3} sx={{ flexGrow: 1, width: "100%" }} size={{ md: 6, xs: 12 }}>
<Stack spacing={3}>
<SettingForm title={t("login.email")} noContainer lgWidth={12}>
<DenseFilledTextField disabled fullWidth value={user?.user.email} />
<Stack direction="row" spacing={1}>
<DenseFilledTextField disabled fullWidth value={user?.user.email} />
<Button variant="outlined" onClick={() => setEmailDialogOpen(true)} sx={{ flexShrink: 0 }}>
{t("setting.changeEmail")}
</Button>
</Stack>
</SettingForm>
<SettingForm title={t("setting.nickname")} noContainer lgWidth={12}>
<DenseFilledTextField
@ -154,10 +162,44 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => {
{...bindPopover(profileSettingPopup)}
/>
</SettingForm>
<SettingForm title={t("setting.shareDefaultPrivate")} noContainer lgWidth={6}>
<DenseSelect
fullWidth
value={
setting.share_default_private === undefined || setting.share_default_private === null
? ""
: setting.share_default_private
? "true"
: "false"
}
onChange={(e) => {
const v = e.target.value as string;
dispatch(sendUpdateUserSetting({ share_default_private: v })).then(() => {
setSetting({
...setting,
share_default_private: v === "" ? undefined : v === "true",
});
});
}}
>
<SquareMenuItem value="">
<ListItemText primary={t("setting.shareSiteDefault")} />
</SquareMenuItem>
<SquareMenuItem value="true">
<ListItemText primary={t("setting.sharePrivateOn")} />
</SquareMenuItem>
<SquareMenuItem value="false">
<ListItemText primary={t("setting.sharePrivateOff")} />
</SquareMenuItem>
</DenseSelect>
<FormHelperText>{t("setting.shareDefaultPrivateDes")}</FormHelperText>
</SettingForm>
</Grid2>
</Stack>
</Grid2>
</Grid2>
<ChangeEmailDialog open={emailDialogOpen} onClose={() => setEmailDialogOpen(false)} currentEmail={user?.user.email} />
</Stack>
);
};

@ -28,6 +28,12 @@ const profileSettingOptions: {
description: string;
icon?: ((props: SvgIconProps) => JSX.Element) | typeof Eye;
}[] = [
{
value: ShareLinksInProfileLevel.site_default,
label: "application:setting.shareSiteDefault",
description: "application:setting.shareSiteDefaultDes",
icon: Globe,
},
{
value: ShareLinksInProfileLevel.public_share_only,
label: "application:setting.publicShareOnly",
@ -53,7 +59,7 @@ export const useProfileSettingSummary = (value: ShareLinksInProfileLevel) => {
const summary = useMemo(() => {
const option = profileSettingOptions.find((opt) => opt.value === (value ?? ""));
return option ? t(option.label) : t("application:setting.publicShareOnly");
return option ? t(option.label) : t("application:setting.shareSiteDefault");
}, [value, t]);
return summary;

@ -3,13 +3,14 @@ import { useTranslation } from "react-i18next";
import { useSnackbar } from "notistack";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import React, { useEffect, useState } from "react";
import { Box, DialogContent, FormControl, Stack, styled, Typography, useMediaQuery, useTheme } from "@mui/material";
import { Box, DialogContent, FormControl, Stack, styled, Tooltip, Typography, useMediaQuery, useTheme } from "@mui/material";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import AutoHeight from "../../../Common/AutoHeight.tsx";
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
import { get2FAInitSecret, sendUpdateUserSetting } from "../../../../api/api.ts";
import { QRCodeSVG } from "qrcode.react";
import SessionManager from "../../../../session";
import { copyToClipboard } from "../../../../util";
import { MuiOtpInput } from "mui-one-time-password-input";
export interface Enable2FADialogProps {
@ -129,6 +130,25 @@ const Enable2FADialog = ({ open, onClose, on2FAEnabled }: Enable2FADialogProps)
<Typography variant={"body2"} sx={{ mt: 1 }}>
{t("setting.2faDescription")}
</Typography>
<Tooltip title={t("setting.copySecret")}>
<Typography
variant={"body2"}
onClick={() => {
copyToClipboard(secret);
enqueueSnackbar({ message: t("setting.secretCopied"), variant: "success" });
}}
sx={{
mt: 1,
fontFamily: "monospace",
letterSpacing: 1,
cursor: "pointer",
wordBreak: "break-all",
userSelect: "all",
}}
>
{secret}
</Typography>
</Tooltip>
<Typography variant={"body2"} sx={{ mt: 1 }}>
{t("setting.inputCurrent2FACode")}
</Typography>

@ -1,5 +1,6 @@
import {
Box,
Checkbox,
Chip,
Grid,
ListItemIcon,
@ -30,6 +31,7 @@ import TimeBadge from "../../Common/TimeBadge.tsx";
import { DenseDivider, SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
import FileTypeIcon from "../../FileManager/Explorer/FileTypeIcon.tsx";
import Clipboard from "../../Icons/Clipboard.tsx";
import CloudDownloadOutlined from "../../Icons/CloudDownloadOutlined.tsx";
import DeleteOutlined from "../../Icons/DeleteOutlined.tsx";
import Eye from "../../Icons/Eye.tsx";
import LinkEdit from "../../Icons/LinkEdit.tsx";
@ -42,6 +44,9 @@ export interface ShareCardProps {
onLoad?: () => void;
loading?: boolean;
onShareDeleted: (id: string) => void;
selecting?: boolean;
selected?: boolean;
onToggleSelect?: (id: string) => void;
}
interface ActionMenuProps extends MenuProps {
@ -127,7 +132,7 @@ const ActionMenu = ({ share, onShareDeleted, onClose, ...rest }: ActionMenuProps
);
};
const ShareCard = ({ share, onShareDeleted, onLoad, loading }: ShareCardProps) => {
const ShareCard = ({ share, onShareDeleted, onLoad, loading, selecting, selected, onToggleSelect }: ShareCardProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { ref, inView } = useInView({
@ -162,18 +167,27 @@ const ShareCard = ({ share, onShareDeleted, onLoad, loading }: ShareCardProps) =
expanded={false}
onContextMenu={(e) => {
e.preventDefault();
if (selecting) {
return;
}
if (share?.owner?.id === user?.user.id) {
popupState.open(e);
}
}}
sx={{ p: 0, minHeight: 0, width: "100%", textAlign: "left" }}
{...(share?.owner?.id != user?.user.id
{...(selecting && share?.owner?.id == user?.user.id
? {
onClick: () => {
window.open(share?.url ?? "#", "_blank");
share && onToggleSelect?.(share.id);
},
}
: bindTrigger(popupState))}
: share?.owner?.id != user?.user.id
? {
onClick: () => {
window.open(share?.url ?? "#", "_blank");
},
}
: bindTrigger(popupState))}
>
<Box
sx={{
@ -187,7 +201,9 @@ const ShareCard = ({ share, onShareDeleted, onLoad, loading }: ShareCardProps) =
p: 1.5,
}}
>
{share ? (
{selecting ? (
<Checkbox size="small" checked={selected ?? false} disableRipple sx={{ p: 0, mt: 0.5 }} />
) : share ? (
<FileTypeIcon
sx={{
fontSize: 32,
@ -218,6 +234,13 @@ const ShareCard = ({ share, onShareDeleted, onLoad, loading }: ShareCardProps) =
<Chip size="small" label={t("application:share.expired")} sx={{ ml: 1, height: 18 }} />
)}
</Box>
{share?.note && (
<Tooltip title={share.note}>
<NoWrapTypography variant={"body2"} color={"text.secondary"}>
{share.note}
</NoWrapTypography>
</Tooltip>
)}
<Box>
<Tooltip title={share?.name ?? ""}>
<NoWrapTypography variant={"body2"} color={"text.secondary"}>
@ -248,6 +271,12 @@ const ShareCard = ({ share, onShareDeleted, onLoad, loading }: ShareCardProps) =
>
<Eye sx={{ ml: 1, mr: 0.5 }} fontSize={"small"} />
{share?.visited ?? 0}
<Tooltip title={t("application:share.downloads")}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<CloudDownloadOutlined sx={{ ml: 1, mr: 0.5 }} fontSize={"small"} />
{share?.downloaded ?? 0}
</Box>
</Tooltip>
</Box>
</Box>
)}

@ -1,11 +1,14 @@
import * as React from "react";
import { useCallback, useState } from "react";
import { Box, Container, FormControl, Grid, ListItemText, SelectChangeEvent } from "@mui/material";
import { Box, Button, Container, FormControl, Grid, ListItemText, SelectChangeEvent, Stack } from "@mui/material";
import { useSnackbar } from "notistack";
import { useTranslation } from "react-i18next";
import PageHeader from "../PageHeader.tsx";
import { getShares } from "../../../api/api.ts";
import { getShares, sendDeleteShares } from "../../../api/api.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { confirmOperation } from "../../../redux/thunks/dialog.ts";
import Nothing from "../../Common/Nothing.tsx";
import { DefaultCloseAction } from "../../Common/Snackbar/snackbar.tsx";
import { setSelected } from "../../../redux/fileManagerSlice.ts";
import ShareCard from "./ShareCard.tsx";
import { Share } from "../../../api/explorer.ts";
@ -18,10 +21,13 @@ const defaultPageSize = 50;
const ShareList = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { enqueueSnackbar } = useSnackbar();
const [nextPageToken, setNextPageToken] = useState<string | undefined>("");
const [shares, setShares] = useState<Share[]>([]);
const [loading, setLoading] = useState(false);
const [orderDirection, setOrderDirection] = useState("desc");
const [selecting, setSelecting] = useState(false);
const [selected, setSelectedIds] = useState<Set<string>>(new Set());
const loadNextPage = useCallback(
(originShares: Share[], token?: string, direction?: string) => () => {
@ -62,6 +68,40 @@ const ShareList = () => {
[setShares],
);
const onToggleSelect = useCallback(
(id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
},
[setSelectedIds],
);
const exitSelecting = useCallback(() => {
setSelecting(false);
setSelectedIds(new Set());
}, []);
const deleteSelected = useCallback(() => {
dispatch(confirmOperation(t("fileManager.deleteShareWarning"))).then(() => {
dispatch(sendDeleteShares([...selected])).then(() => {
enqueueSnackbar({
message: t("application:share.shareCanceled"),
variant: "success",
action: DefaultCloseAction,
});
setShares((shares) => shares.filter((share) => !selected.has(share.id)));
exitSelecting();
});
});
}, [dispatch, selected, t, enqueueSnackbar, exitSelecting]);
const onSelectChange = useCallback(
(e: SelectChangeEvent<unknown>) => {
setOrderDirection(e.target.value as string);
@ -75,28 +115,53 @@ const ShareList = () => {
<Container maxWidth="lg">
<PageHeader
secondaryAction={
<FormControl variant="outlined">
<DenseSelect variant="outlined" value={orderDirection} onChange={onSelectChange}>
<SquareMenuItem value={"desc"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
<Stack direction="row" spacing={1} alignItems="center">
{selecting && (
<>
<Button
size="small"
variant="text"
onClick={() => setSelectedIds(new Set(shares.map((s) => s.id)))}
>
{t("application:share.createdAtDesc")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={"asc"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
{t("application:fileManager.selectAll")}
</Button>
<Button
size="small"
variant="text"
color="error"
disabled={selected.size == 0}
onClick={deleteSelected}
>
{t("application:share.createdAtAsc")}
</ListItemText>
</SquareMenuItem>
</DenseSelect>
</FormControl>
{t("fileManager.delete")} ({selected.size})
</Button>
</>
)}
<Button size="small" variant="text" onClick={selecting ? exitSelecting : () => setSelecting(true)}>
{selecting ? t("common:cancel") : t("common:select")}
</Button>
<FormControl variant="outlined">
<DenseSelect variant="outlined" value={orderDirection} onChange={onSelectChange}>
<SquareMenuItem value={"desc"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("application:share.createdAtDesc")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={"asc"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("application:share.createdAtAsc")}
</ListItemText>
</SquareMenuItem>
</DenseSelect>
</FormControl>
</Stack>
}
onRefresh={() => refresh()}
loading={loading}
@ -105,7 +170,14 @@ const ShareList = () => {
<Grid container spacing={1}>
{shares.map((share) => (
<ShareCard share={share} key={share.id} onShareDeleted={onShareDeleted} />
<ShareCard
share={share}
key={share.id}
onShareDeleted={onShareDeleted}
selecting={selecting}
selected={selected.has(share.id)}
onToggleSelect={onToggleSelect}
/>
))}
{nextPageToken != undefined && (
<>

@ -140,12 +140,13 @@ const DownloadList = () => {
)}
{downloadingTasks == undefined && <TaskCard onLoad={loadDownloading} loading={true} />}
{downloadingTasks && downloadingTasks.map((task) => <TaskCard showProgress key={task.id} task={task} />)}
{downloadingTasks &&
downloadingTasks.map((task) => <TaskCard showProgress key={task.id} task={task} onRetried={refresh} />)}
<Typography variant={"h5"} sx={{ mb: 2, mt: 3 }} color={"text.secondary"} fontWeight={500}>
{t("download.finished")}
</Typography>
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
<TaskCard key={task.id} task={task} onRetried={refresh} />
))}
{nextPageToken != undefined && (
<TaskCard onLoad={loadNextPage(tasks, nextPageToken)} loading={true} key={nextPageToken} />

@ -17,7 +17,8 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useInView } from "react-intersection-observer";
import { FileType } from "../../../api/explorer.ts";
import { TaskResponse, TaskType } from "../../../api/workflow.ts";
import { getTasksPhaseProgress } from "../../../api/api.ts";
import { TaskResponse, TaskStatus, TaskType } from "../../../api/workflow.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { DefaultButton } from "../../Common/StyledComponents.tsx";
import FileIcon from "../../FileManager/Explorer/FileIcon.tsx";
@ -96,6 +97,7 @@ export interface TaskCardProps {
showProgress?: boolean;
task?: TaskResponse;
onLoad?: () => void;
onRetried?: () => void;
}
const taskIconsMap: {
@ -107,7 +109,7 @@ const taskIconsMap: {
[TaskType.import]: ArrowImport,
};
const TaskCard = ({ loading, showProgress, onLoad, task }: TaskCardProps) => {
const TaskCard = ({ loading, showProgress, onLoad, onRetried, task }: TaskCardProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
@ -119,6 +121,42 @@ const TaskCard = ({ loading, showProgress, onLoad, task }: TaskCardProps) => {
});
const [expanded, setExpanded] = useState(false);
const [transferPercent, setTransferPercent] = useState<number | undefined>(undefined);
// During the transfer phase the summary's download counters are frozen at
// 100%; poll the live phase progress so the card bar keeps moving while
// files are imported to storage.
const transferring =
task?.type === TaskType.remote_download &&
task?.status === TaskStatus.processing &&
task?.summary?.phase === "transfer";
useEffect(() => {
if (!transferring || !task) {
setTransferPercent(undefined);
return;
}
let cancelled = false;
const fetchProgress = () => {
dispatch(getTasksPhaseProgress(task.id))
.then((res) => {
if (cancelled) {
return;
}
const upload = res?.["upload"];
if (upload && upload.total > 0) {
setTransferPercent((100 * upload.current) / upload.total);
}
})
.catch(() => {});
};
fetchProgress();
const iv = setInterval(fetchProgress, 2500);
return () => {
cancelled = true;
clearInterval(iv);
};
}, [transferring, task?.id]);
useEffect(() => {
if (!inView) {
@ -151,8 +189,10 @@ const TaskCard = ({ loading, showProgress, onLoad, task }: TaskCardProps) => {
fullWidth
percentage={
showProgress
? ((task?.summary?.props?.download?.downloaded ?? 0) * 100) /
Math.max(task?.summary?.props?.download?.total ?? 0, 1)
? transferring && transferPercent != undefined
? transferPercent
: ((task?.summary?.props?.download?.downloaded ?? 0) * 100) /
Math.max(task?.summary?.props?.download?.total ?? 0, 1)
: undefined
}
startIcon={
@ -208,13 +248,14 @@ const TaskCard = ({ loading, showProgress, onLoad, task }: TaskCardProps) => {
status={task.status}
error={task.error}
summary={task.summary}
transferPercent={transferPercent}
/>
)}
</Typography>
</Box>
</SummaryButton>
</AccordionSummary>
<AccordionDetails>{task && <TaskDetail task={task} downloading={showProgress} />}</AccordionDetails>
<AccordionDetails>{task && <TaskDetail task={task} downloading={showProgress} onRetried={onRetried} />}</AccordionDetails>
</Accordion>
);
};

@ -10,9 +10,12 @@ import {
TableRow,
Typography,
} from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { sendCancelTask, sendDeleteTask, sendRetryTask } from "../../../api/api.ts";
import { TaskResponse, TaskStatus } from "../../../api/workflow.ts";
import { StyledTableContainerPaper } from "../../Common/StyledComponents.tsx";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { SecondaryLoadingButton, StyledTableContainerPaper } from "../../Common/StyledComponents.tsx";
import DownloadFileList from "./DownloadFileList.tsx";
import TaskProgress from "./TaskProgress.tsx";
import TaskProps from "./TaskProps.tsx";
@ -20,10 +23,44 @@ import TaskProps from "./TaskProps.tsx";
export interface TaskDetailProps {
task: TaskResponse;
downloading?: boolean;
onRetried?: () => void;
}
const TaskDetail = ({ task, downloading }: TaskDetailProps) => {
const TaskDetail = ({ task, downloading, onRetried }: TaskDetailProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const [retrying, setRetrying] = useState(false);
const [canceling, setCanceling] = useState(false);
const retry = () => {
setRetrying(true);
dispatch(sendRetryTask(task.id))
.then(() => onRetried?.())
.catch(() => {})
.finally(() => setRetrying(false));
};
const cancel = () => {
setCanceling(true);
dispatch(sendCancelTask(task.id))
.then(() => onRetried?.())
.catch(() => {})
.finally(() => setCanceling(false));
};
const [deleting, setDeleting] = useState(false);
const deleteRecord = () => {
setDeleting(true);
dispatch(sendDeleteTask(task.id))
.then(() => onRetried?.())
.catch(() => {})
.finally(() => setDeleting(false));
};
const cancelable = task.status == TaskStatus.queued || task.status == TaskStatus.suspending;
const deletable =
task.status == TaskStatus.completed || task.status == TaskStatus.error || task.status == TaskStatus.canceled;
return (
<Stack spacing={2}>
<Stack spacing={1}>
@ -46,7 +83,48 @@ const TaskDetail = ({ task, downloading }: TaskDetailProps) => {
})}
</Alert>
)}
{task.status == TaskStatus.error && <Alert severity={"error"}>{task.error}</Alert>}
{cancelable && (
<SecondaryLoadingButton
size="small"
variant="outlined"
color="error"
loading={canceling}
onClick={cancel}
sx={{ alignSelf: "flex-start" }}
>
{t("common:cancel")}
</SecondaryLoadingButton>
)}
{deletable && (
<SecondaryLoadingButton
size="small"
variant="outlined"
color="error"
loading={deleting}
onClick={deleteRecord}
sx={{ alignSelf: "flex-start" }}
>
{t("common:delete")}
</SecondaryLoadingButton>
)}
{task.status == TaskStatus.error && (
<Alert
severity={"error"}
action={
<SecondaryLoadingButton
size="small"
variant="outlined"
color="error"
loading={retrying}
onClick={retry}
>
{t("uploader.retry")}
</SecondaryLoadingButton>
}
>
{task.error}
</Alert>
)}
<TaskProgress
taskId={task.id}
taskStatus={task.status}

@ -105,7 +105,7 @@ const TaskList = () => {
title={t("application:navbar.taskQueue")}
/>
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
<TaskCard key={task.id} task={task} onRetried={refresh} />
))}
{nextPageToken != undefined && (
<TaskCard onLoad={loadNextPage(tasks, nextPageToken)} loading={true} key={nextPageToken} />

@ -27,6 +27,8 @@ export interface TaskSummaryStatusProps {
summary?: TaskSummary;
error?: string;
simplified?: boolean;
// Live transfer-phase progress percent for remote downloads.
transferPercent?: number;
}
interface TaskStatusContentProps {
@ -46,7 +48,7 @@ const TaskStatusContent = forwardRef(({ icon, title, color, ...props }: TaskStat
);
});
const TaskSummaryStatus = ({ type, status, summary, error, simplified }: TaskSummaryStatusProps) => {
const TaskSummaryStatus = ({ type, status, summary, error, simplified, transferPercent }: TaskSummaryStatusProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const theme = useTheme();
@ -111,7 +113,11 @@ const TaskSummaryStatus = ({ type, status, summary, error, simplified }: TaskSum
} else if (summary?.phase == "transfer") {
return (
<TaskStatusContent
title={t("download.transferring")}
title={
transferPercent != undefined
? `${t("download.transferring")} ${transferPercent.toFixed(0)}%`
: t("download.transferring")
}
icon={<ArrowSyncCircleFilledSpin fontSize={"small"} />}
color={theme.palette.primary.main}
/>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save