diff --git a/gen/go/v1/config.pb.go b/gen/go/v1/config.pb.go index aa1359d5..cb87d846 100644 --- a/gen/go/v1/config.pb.go +++ b/gen/go/v1/config.pb.go @@ -34,6 +34,9 @@ const ( // When granted to an authorizedClient, the client will be able to write the configuration to the server. // When granted to a knownHost, the known host will be able to write configuration. Multihost_Permission_PERMISSION_READ_WRITE_CONFIG Multihost_Permission_Type = 3 // read and write configuration for the resource in scope. + // When granted to an authorizedClient, the server will push repos marked as 'shared' to the client. + // This permission does not use scopes — if present, all shared repos are pushed. + Multihost_Permission_PERMISSION_RECEIVE_SHARED_REPOS Multihost_Permission_Type = 4 ) // Enum value maps for Multihost_Permission_Type. @@ -43,12 +46,14 @@ var ( 1: "PERMISSION_READ_OPERATIONS", 2: "PERMISSION_READ_CONFIG", 3: "PERMISSION_READ_WRITE_CONFIG", + 4: "PERMISSION_RECEIVE_SHARED_REPOS", } Multihost_Permission_Type_value = map[string]int32{ - "PERMISSION_UNKNOWN": 0, - "PERMISSION_READ_OPERATIONS": 1, - "PERMISSION_READ_CONFIG": 2, - "PERMISSION_READ_WRITE_CONFIG": 3, + "PERMISSION_UNKNOWN": 0, + "PERMISSION_READ_OPERATIONS": 1, + "PERMISSION_READ_CONFIG": 2, + "PERMISSION_READ_WRITE_CONFIG": 3, + "PERMISSION_RECEIVE_SHARED_REPOS": 4, } ) @@ -2371,7 +2376,7 @@ const file_v1_config_proto_rawDesc = "" + "\x05repos\x18\x03 \x03(\v2\b.v1.RepoR\x05repos\x12\x1e\n" + "\x05plans\x18\x04 \x03(\v2\b.v1.PlanR\x05plans\x12\x1c\n" + "\x04auth\x18\x05 \x01(\v2\b.v1.AuthR\x04auth\x12&\n" + - "\tmultihost\x18\a \x01(\v2\r.v1.MultihostR\x04sync\"\x9f\a\n" + + "\tmultihost\x18\a \x01(\v2\r.v1.MultihostR\x04sync\"\xc5\a\n" + "\tMultihost\x12*\n" + "\bidentity\x18\x01 \x01(\v2\x0e.v1.PrivateKeyR\bidentity\x123\n" + "\vknown_hosts\x18\x02 \x03(\v2\x12.v1.Multihost.PeerR\n" + @@ -2392,16 +2397,17 @@ const file_v1_config_proto_rawDesc = "" + "\x0fexpires_at_unix\x18\x04 \x01(\x03R\rexpiresAtUnix\x12\x19\n" + "\bmax_uses\x18\x05 \x01(\x05R\amaxUses\x12\x12\n" + "\x04uses\x18\x06 \x01(\x05R\x04uses\x12:\n" + - "\vpermissions\x18\a \x03(\v2\x18.v1.Multihost.PermissionR\vpermissions\x1a\xd5\x01\n" + + "\vpermissions\x18\a \x03(\v2\x18.v1.Multihost.PermissionR\vpermissions\x1a\xfb\x01\n" + "\n" + "Permission\x121\n" + "\x04type\x18\x01 \x01(\x0e2\x1d.v1.Multihost.Permission.TypeR\x04type\x12\x16\n" + - "\x06scopes\x18\x02 \x03(\tR\x06scopes\"|\n" + + "\x06scopes\x18\x02 \x03(\tR\x06scopes\"\xa1\x01\n" + "\x04Type\x12\x16\n" + "\x12PERMISSION_UNKNOWN\x10\x00\x12\x1e\n" + "\x1aPERMISSION_READ_OPERATIONS\x10\x01\x12\x1a\n" + "\x16PERMISSION_READ_CONFIG\x10\x02\x12 \n" + - "\x1cPERMISSION_READ_WRITE_CONFIG\x10\x03\"\xd2\x03\n" + + "\x1cPERMISSION_READ_WRITE_CONFIG\x10\x03\x12#\n" + + "\x1fPERMISSION_RECEIVE_SHARED_REPOS\x10\x04\"\xd2\x03\n" + "\x04Repo\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + "\x03uri\x18\x02 \x01(\tR\x03uri\x12\x12\n" + diff --git a/internal/api/backresthandler.go b/internal/api/backresthandler.go index bfd72dc1..7842d0dd 100644 --- a/internal/api/backresthandler.go +++ b/internal/api/backresthandler.go @@ -972,15 +972,30 @@ func (s *BackrestHandler) GeneratePairingToken(ctx context.Context, req *connect expiresAt = now + req.Msg.TtlSeconds } + // Apply default permissions if none specified: + // share operations (read ops with wildcard scope) + receive shared repos (no scope needed) + perms := req.Msg.Permissions + if len(perms) == 0 { + perms = []*v1.Multihost_Permission{ + { + Type: v1.Multihost_Permission_PERMISSION_READ_OPERATIONS, + Scopes: []string{"*"}, + }, + { + Type: v1.Multihost_Permission_PERMISSION_RECEIVE_SHARED_REPOS, + }, + } + } + // Store the pairing token in config pairingToken := &v1.Multihost_PairingToken{ - Secret: secret, - Label: req.Msg.Label, + Secret: secret, + Label: req.Msg.Label, CreatedAtUnix: now, ExpiresAtUnix: expiresAt, - MaxUses: req.Msg.MaxUses, - Uses: 0, - Permissions: req.Msg.Permissions, + MaxUses: req.Msg.MaxUses, + Uses: 0, + Permissions: perms, } cfg.Multihost.PairingTokens = append(cfg.Multihost.PairingTokens, pairingToken) diff --git a/internal/api/syncapi/permissions/permissions.go b/internal/api/syncapi/permissions/permissions.go index 1c56a67a..132a804e 100644 --- a/internal/api/syncapi/permissions/permissions.go +++ b/internal/api/syncapi/permissions/permissions.go @@ -96,6 +96,9 @@ type PermissionSet struct { // immutable after construction perms map[v1.Multihost_Permission_Type]ScopeSet + // scopelessPerms tracks permission types that were granted without scopes (e.g. PERMISSION_RECEIVE_SHARED_REPOS) + scopelessPerms map[v1.Multihost_Permission_Type]bool + // caches store computed permission checks per scope id and permission type // cache is best-effort and not bounded; PermissionSet is expected to be short-lived (per connection/request) mu sync.RWMutex @@ -105,13 +108,15 @@ type PermissionSet struct { func NewPermissionSet(perms []*v1.Multihost_Permission) (*PermissionSet, error) { permSet := &PermissionSet{ - perms: make(map[v1.Multihost_Permission_Type]ScopeSet), - planCache: make(map[string]map[v1.Multihost_Permission_Type]bool), - repoCache: make(map[string]map[v1.Multihost_Permission_Type]bool), + perms: make(map[v1.Multihost_Permission_Type]ScopeSet), + scopelessPerms: make(map[v1.Multihost_Permission_Type]bool), + planCache: make(map[string]map[v1.Multihost_Permission_Type]bool), + repoCache: make(map[string]map[v1.Multihost_Permission_Type]bool), } for _, perm := range perms { if perm.Scopes == nil { + permSet.scopelessPerms[perm.Type] = true continue } scopeSet, err := NewScopeSet(perm.Scopes) @@ -124,6 +129,18 @@ func NewPermissionSet(perms []*v1.Multihost_Permission) (*PermissionSet, error) return permSet, nil } +// HasPermissionType checks if a permission type is granted, regardless of scopes. +// Use this for scope-less permissions like PERMISSION_RECEIVE_SHARED_REPOS. +func (p *PermissionSet) HasPermissionType(permType v1.Multihost_Permission_Type) bool { + if _, ok := p.scopelessPerms[permType]; ok { + return true + } + if _, ok := p.perms[permType]; ok { + return true + } + return false +} + func (p *PermissionSet) CheckPermissionForPlan(planID string, permType ...v1.Multihost_Permission_Type) bool { for _, pt := range permType { if p.checkPlanSingle(planID, pt) { diff --git a/internal/api/syncapi/syncclient.go b/internal/api/syncapi/syncclient.go index 677d6577..562ecbcc 100644 --- a/internal/api/syncapi/syncclient.go +++ b/internal/api/syncapi/syncclient.go @@ -476,7 +476,8 @@ func (c *syncSessionHandlerClient) HandleSetConfig(ctx context.Context, stream * for _, repo := range item.GetRepos() { c.l.Sugar().Debugf("received repo update: %s", repo.Guid) - if !c.permissions.CheckPermissionForRepo(repo.Id, permissions.PermsCanWriteConfiguration...) { + isFromOriginPeer := repo.GetOriginInstanceId() != "" && repo.GetOriginInstanceId() == c.peer.InstanceId + if !isFromOriginPeer && !c.permissions.CheckPermissionForRepo(repo.Id, permissions.PermsCanWriteConfiguration...) { return NewSyncErrorAuth(fmt.Errorf("peer %q is not allowed to update repo %q", c.peer.InstanceId, repo.Id)) } diff --git a/internal/api/syncapi/syncserver.go b/internal/api/syncapi/syncserver.go index a5f40173..4eb62f53 100644 --- a/internal/api/syncapi/syncserver.go +++ b/internal/api/syncapi/syncserver.go @@ -387,9 +387,13 @@ func (h *syncSessionHandlerServer) sendConfigToClient(stream *bidiSyncCommandStr // sendSharedReposToClient sends repos marked as shared to the client via SetConfig. // This pushes repo configurations to the client so they are added to the client's local config. func (h *syncSessionHandlerServer) sendSharedReposToClient(stream *bidiSyncCommandStream, config *v1.Config) { + if !h.permissions.HasPermissionType(v1.Multihost_Permission_PERMISSION_RECEIVE_SHARED_REPOS) { + return + } + var sharedRepos []*v1.Repo for _, repo := range config.Repos { - if repo.GetShared() && h.permissions.CheckPermissionForRepo(repo.Id, permissions.PermsCanViewConfiguration...) { + if repo.GetShared() { repoCopy := proto.Clone(repo).(*v1.Repo) repoCopy.OriginInstanceId = config.Instance sharedRepos = append(sharedRepos, repoCopy) diff --git a/proto/v1/config.proto b/proto/v1/config.proto index 2f790150..b68ad937 100644 --- a/proto/v1/config.proto +++ b/proto/v1/config.proto @@ -65,6 +65,10 @@ message Multihost { // When granted to an authorizedClient, the client will be able to write the configuration to the server. // When granted to a knownHost, the known host will be able to write configuration. PERMISSION_READ_WRITE_CONFIG = 3; // read and write configuration for the resource in scope. + + // When granted to an authorizedClient, the server will push repos marked as 'shared' to the client. + // This permission does not use scopes — if present, all shared repos are pushed. + PERMISSION_RECEIVE_SHARED_REPOS = 4; } // Scopes are any of '*', 'repo:' or 'plan:','-repo:','-plan:'. // '*' means all repos and plans, 'repo:' means the repo with the given ID, 'plan:' means the plan with the given ID. diff --git a/scripts/testing/run-named.sh b/scripts/testing/run-named.sh new file mode 100755 index 00000000..9e8b7baf --- /dev/null +++ b/scripts/testing/run-named.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# Run a named instance of backrest with its own data directory and ports. +# Multiple instances can run side-by-side for testing sync, multihost, etc. +# +# Usage: ./run-named.sh [backend-port] [vite-port] +# +# Examples: +# ./run-named.sh alice # backend :9901, vite :5181 +# ./run-named.sh bob # backend :9902, vite :5182 +# ./run-named.sh alice 9910 5190 # explicit ports +# +# Data is stored in /tmp/backrest-/ and persists across runs. + +set -euo pipefail + +BASEDIR="$(cd "$(dirname "$0")/../.." && pwd)" +NAME="${1:?Usage: $0 [backend-port] [vite-port]}" + +# Derive deterministic ports from name if not provided. +# Hash the name to a number in a small range to avoid collisions. +name_hash() { + printf '%s' "$1" | cksum | awk '{print $1 % 100}' +} + +OFFSET=$(name_hash "$NAME") +BACKEND_PORT="${2:-$((9900 + OFFSET))}" +VITE_PORT="${3:-$((5180 + OFFSET))}" + +DATADIR="/tmp/backrest-${NAME}" +mkdir -p "$DATADIR" + +PIDS=() + +cleanup() { + echo "" + echo "Shutting down instance '$NAME'..." + for pid in "${PIDS[@]}"; do + kill "$pid" 2>/dev/null || true + done + wait 2>/dev/null || true + echo "Done." +} + +trap cleanup EXIT INT TERM + +echo "=== backrest instance: $NAME ===" +echo " data dir: $DATADIR" +echo " backend: http://127.0.0.1:${BACKEND_PORT}" +echo " webui (vite): http://localhost:${VITE_PORT}" +echo "" + +# Start the Go backend +( + cd "$BASEDIR" + go run ./cmd/backrest \ + -bind-address "127.0.0.1:${BACKEND_PORT}" \ + -config-file "${DATADIR}/config.json" \ + -data-dir "${DATADIR}/data" +) & +PIDS+=($!) + +# Start the vite dev server pointing at this backend +( + cd "$BASEDIR/webui" + UI_BACKEND_URL="http://127.0.0.1:${BACKEND_PORT}" \ + npx vite --port "$VITE_PORT" --strictPort +) & +PIDS+=($!) + +# Wait for any child to exit — if one dies, the trap cleans up the other. +wait -n 2>/dev/null || true diff --git a/webui/gen/ts/v1/config_pb.ts b/webui/gen/ts/v1/config_pb.ts index 5d084bce..0fc68f0f 100644 --- a/webui/gen/ts/v1/config_pb.ts +++ b/webui/gen/ts/v1/config_pb.ts @@ -13,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file v1/config.proto. */ export const file_v1_config: GenFile = /*@__PURE__*/ - fileDesc("Cg92MS9jb25maWcucHJvdG8SAnYxIqwBCgZDb25maWcSDQoFbW9kbm8YASABKAUSDwoHdmVyc2lvbhgGIAEoBRIQCghpbnN0YW5jZRgCIAEoCRIXCgVyZXBvcxgDIAMoCzIILnYxLlJlcG8SFwoFcGxhbnMYBCADKAsyCC52MS5QbGFuEhYKBGF1dGgYBSABKAsyCC52MS5BdXRoEiYKCW11bHRpaG9zdBgHIAEoCzINLnYxLk11bHRpaG9zdFIEc3luYyLUBQoJTXVsdGlob3N0EiAKCGlkZW50aXR5GAEgASgLMg4udjEuUHJpdmF0ZUtleRInCgtrbm93bl9ob3N0cxgCIAMoCzISLnYxLk11bHRpaG9zdC5QZWVyEi4KEmF1dGhvcml6ZWRfY2xpZW50cxgDIAMoCzISLnYxLk11bHRpaG9zdC5QZWVyEjIKDnBhaXJpbmdfdG9rZW5zGAQgAygLMhoudjEuTXVsdGlob3N0LlBhaXJpbmdUb2tlbhqcAQoEUGVlchITCgtpbnN0YW5jZV9pZBgBIAEoCRIUCgVrZXlpZBgCIAEoCVIFa2V5SWQSLQoLcGVybWlzc2lvbnMYBSADKAsyGC52MS5NdWx0aWhvc3QuUGVybWlzc2lvbhIUCgxpbnN0YW5jZV91cmwYBCABKAkSHgoWaW5pdGlhbF9wYWlyaW5nX3NlY3JldBgGIAEoCUoECAMQBBquAQoMUGFpcmluZ1Rva2VuEg4KBnNlY3JldBgBIAEoCRINCgVsYWJlbBgCIAEoCRIXCg9jcmVhdGVkX2F0X3VuaXgYAyABKAMSFwoPZXhwaXJlc19hdF91bml4GAQgASgDEhAKCG1heF91c2VzGAUgASgFEgwKBHVzZXMYBiABKAUSLQoLcGVybWlzc2lvbnMYByADKAsyGC52MS5NdWx0aWhvc3QuUGVybWlzc2lvbhrHAQoKUGVybWlzc2lvbhIrCgR0eXBlGAEgASgOMh0udjEuTXVsdGlob3N0LlBlcm1pc3Npb24uVHlwZRIOCgZzY29wZXMYAiADKAkifAoEVHlwZRIWChJQRVJNSVNTSU9OX1VOS05PV04QABIeChpQRVJNSVNTSU9OX1JFQURfT1BFUkFUSU9OUxABEhoKFlBFUk1JU1NJT05fUkVBRF9DT05GSUcQAhIgChxQRVJNSVNTSU9OX1JFQURfV1JJVEVfQ09ORklHEAMixwIKBFJlcG8SCgoCaWQYASABKAkSCwoDdXJpGAIgASgJEgwKBGd1aWQYCyABKAkSEAoIcGFzc3dvcmQYAyABKAkSCwoDZW52GAQgAygJEg0KBWZsYWdzGAUgAygJEiUKDHBydW5lX3BvbGljeRgGIAEoCzIPLnYxLlBydW5lUG9saWN5EiUKDGNoZWNrX3BvbGljeRgJIAEoCzIPLnYxLkNoZWNrUG9saWN5EhcKBWhvb2tzGAcgAygLMggudjEuSG9vaxITCgthdXRvX3VubG9jaxgIIAEoCBIXCg9hdXRvX2luaXRpYWxpemUYDCABKAgSKQoOY29tbWFuZF9wcmVmaXgYCiABKAsyES52MS5Db21tYW5kUHJlZml4Eg4KBnNoYXJlZBgNIAEoCBIaChJvcmlnaW5faW5zdGFuY2VfaWQYDiABKAkihgIKBFBsYW4SCgoCaWQYASABKAkSDAoEcmVwbxgCIAEoCRINCgVwYXRocxgEIAMoCRIQCghleGNsdWRlcxgFIAMoCRIRCglpZXhjbHVkZXMYCSADKAkSHgoIc2NoZWR1bGUYDCABKAsyDC52MS5TY2hlZHVsZRImCglyZXRlbnRpb24YByABKAsyEy52MS5SZXRlbnRpb25Qb2xpY3kSFwoFaG9va3MYCCADKAsyCC52MS5Ib29rEiIKDGJhY2t1cF9mbGFncxgKIAMoCVIMYmFja3VwX2ZsYWdzEhkKEXNraXBfaWZfdW5jaGFuZ2VkGA0gASgISgQIAxAESgQIBhAHSgQICxAMIooCCg1Db21tYW5kUHJlZml4Ei4KB2lvX25pY2UYASABKA4yHS52MS5Db21tYW5kUHJlZml4LklPTmljZUxldmVsEjAKCGNwdV9uaWNlGAIgASgOMh4udjEuQ29tbWFuZFByZWZpeC5DUFVOaWNlTGV2ZWwiWwoLSU9OaWNlTGV2ZWwSDgoKSU9fREVGQVVMVBAAEhYKEklPX0JFU1RfRUZGT1JUX0xPVxABEhcKE0lPX0JFU1RfRUZGT1JUX0hJR0gQAhILCgdJT19JRExFEAMiOgoMQ1BVTmljZUxldmVsEg8KC0NQVV9ERUZBVUxUEAASDAoIQ1BVX0hJR0gQARILCgdDUFVfTE9XEAIilwIKD1JldGVudGlvblBvbGljeRIcChJwb2xpY3lfa2VlcF9sYXN0X24YCiABKAVIABJGChRwb2xpY3lfdGltZV9idWNrZXRlZBgLIAEoCzImLnYxLlJldGVudGlvblBvbGljeS5UaW1lQnVja2V0ZWRDb3VudHNIABIZCg9wb2xpY3lfa2VlcF9hbGwYDCABKAhIABp5ChJUaW1lQnVja2V0ZWRDb3VudHMSDgoGaG91cmx5GAEgASgFEg0KBWRhaWx5GAIgASgFEg4KBndlZWtseRgDIAEoBRIPCgdtb250aGx5GAQgASgFEg4KBnllYXJseRgFIAEoBRITCgtrZWVwX2xhc3RfbhgGIAEoBUIICgZwb2xpY3kiYwoLUHJ1bmVQb2xpY3kSHgoIc2NoZWR1bGUYAiABKAsyDC52MS5TY2hlZHVsZRIYChBtYXhfdW51c2VkX2J5dGVzGAMgASgDEhoKEm1heF91bnVzZWRfcGVyY2VudBgEIAEoASJzCgtDaGVja1BvbGljeRIeCghzY2hlZHVsZRgBIAEoCzIMLnYxLlNjaGVkdWxlEhgKDnN0cnVjdHVyZV9vbmx5GGQgASgISAASIgoYcmVhZF9kYXRhX3N1YnNldF9wZXJjZW50GGUgASgBSABCBgoEbW9kZSLrAQoIU2NoZWR1bGUSEgoIZGlzYWJsZWQYASABKAhIABIOCgRjcm9uGAIgASgJSAASGgoQbWF4RnJlcXVlbmN5RGF5cxgDIAEoBUgAEhsKEW1heEZyZXF1ZW5jeUhvdXJzGAQgASgFSAASIQoFY2xvY2sYBSABKA4yEi52MS5TY2hlZHVsZS5DbG9jayJTCgVDbG9jaxIRCg1DTE9DS19ERUZBVUxUEAASDwoLQ0xPQ0tfTE9DQUwQARINCglDTE9DS19VVEMQAhIXChNDTE9DS19MQVNUX1JVTl9USU1FEANCCgoIc2NoZWR1bGUigA0KBEhvb2sSJgoKY29uZGl0aW9ucxgBIAMoDjISLnYxLkhvb2suQ29uZGl0aW9uEiIKCG9uX2Vycm9yGAIgASgOMhAudjEuSG9vay5PbkVycm9yEioKDmFjdGlvbl9jb21tYW5kGGQgASgLMhAudjEuSG9vay5Db21tYW5kSAASKgoOYWN0aW9uX3dlYmhvb2sYZSABKAsyEC52MS5Ib29rLldlYmhvb2tIABIqCg5hY3Rpb25fZGlzY29yZBhmIAEoCzIQLnYxLkhvb2suRGlzY29yZEgAEigKDWFjdGlvbl9nb3RpZnkYZyABKAsyDy52MS5Ib29rLkdvdGlmeUgAEiYKDGFjdGlvbl9zbGFjaxhoIAEoCzIOLnYxLkhvb2suU2xhY2tIABIsCg9hY3Rpb25fc2hvdXRycnIYaSABKAsyES52MS5Ib29rLlNob3V0cnJySAASNAoTYWN0aW9uX2hlYWx0aGNoZWNrcxhqIAEoCzIVLnYxLkhvb2suSGVhbHRoY2hlY2tzSAASLAoPYWN0aW9uX3RlbGVncmFtGGsgASgLMhEudjEuSG9vay5UZWxlZ3JhbUgAGhoKB0NvbW1hbmQSDwoHY29tbWFuZBgBIAEoCRqDAQoHV2ViaG9vaxITCgt3ZWJob29rX3VybBgBIAEoCRInCgZtZXRob2QYAiABKA4yFy52MS5Ib29rLldlYmhvb2suTWV0aG9kEhAKCHRlbXBsYXRlGGQgASgJIigKBk1ldGhvZBILCgdVTktOT1dOEAASBwoDR0VUEAESCAoEUE9TVBACGjAKB0Rpc2NvcmQSEwoLd2ViaG9va191cmwYASABKAkSEAoIdGVtcGxhdGUYAiABKAkaZQoGR290aWZ5EhAKCGJhc2VfdXJsGAEgASgJEg0KBXRva2VuGAMgASgJEhAKCHRlbXBsYXRlGGQgASgJEhYKDnRpdGxlX3RlbXBsYXRlGGUgASgJEhAKCHByaW9yaXR5GGYgASgFGi4KBVNsYWNrEhMKC3dlYmhvb2tfdXJsGAEgASgJEhAKCHRlbXBsYXRlGAIgASgJGjIKCFNob3V0cnJyEhQKDHNob3V0cnJyX3VybBgBIAEoCRIQCgh0ZW1wbGF0ZRgCIAEoCRo1CgxIZWFsdGhjaGVja3MSEwoLd2ViaG9va191cmwYASABKAkSEAoIdGVtcGxhdGUYAiABKAkaQAoIVGVsZWdyYW0SEQoJYm90X3Rva2VuGAEgASgJEg8KB2NoYXRfaWQYAiABKAkSEAoIdGVtcGxhdGUYAyABKAki9QMKCUNvbmRpdGlvbhIVChFDT05ESVRJT05fVU5LTk9XThAAEhcKE0NPTkRJVElPTl9BTllfRVJST1IQARIcChhDT05ESVRJT05fU05BUFNIT1RfU1RBUlQQAhIaChZDT05ESVRJT05fU05BUFNIT1RfRU5EEAMSHAoYQ09ORElUSU9OX1NOQVBTSE9UX0VSUk9SEAQSHgoaQ09ORElUSU9OX1NOQVBTSE9UX1dBUk5JTkcQBRIeChpDT05ESVRJT05fU05BUFNIT1RfU1VDQ0VTUxAGEh4KGkNPTkRJVElPTl9TTkFQU0hPVF9TS0lQUEVEEAcSGQoVQ09ORElUSU9OX1BSVU5FX1NUQVJUEGQSGQoVQ09ORElUSU9OX1BSVU5FX0VSUk9SEGUSGwoXQ09ORElUSU9OX1BSVU5FX1NVQ0NFU1MQZhIaChVDT05ESVRJT05fQ0hFQ0tfU1RBUlQQyAESGgoVQ09ORElUSU9OX0NIRUNLX0VSUk9SEMkBEhwKF0NPTkRJVElPTl9DSEVDS19TVUNDRVNTEMoBEhsKFkNPTkRJVElPTl9GT1JHRVRfU1RBUlQQrAISGwoWQ09ORElUSU9OX0ZPUkdFVF9FUlJPUhCtAhIdChhDT05ESVRJT05fRk9SR0VUX1NVQ0NFU1MQrgIiqQEKB09uRXJyb3ISEwoPT05fRVJST1JfSUdOT1JFEAASEwoPT05fRVJST1JfQ0FOQ0VMEAESEgoOT05fRVJST1JfRkFUQUwQAhIaChZPTl9FUlJPUl9SRVRSWV8xTUlOVVRFEGQSHAoYT05fRVJST1JfUkVUUllfMTBNSU5VVEVTEGUSJgoiT05fRVJST1JfUkVUUllfRVhQT05FTlRJQUxfQkFDS09GRhBnQggKBmFjdGlvbiIxCgRBdXRoEhAKCGRpc2FibGVkGAEgASgIEhcKBXVzZXJzGAIgAygLMggudjEuVXNlciI7CgRVc2VyEgwKBG5hbWUYASABKAkSGQoPcGFzc3dvcmRfYmNyeXB0GAIgASgJSABCCgoIcGFzc3dvcmRCLFoqZ2l0aHViLmNvbS9nYXJldGhnZW9yZ2UvYmFja3Jlc3QvZ2VuL2dvL3YxYgZwcm90bzM", [file_google_protobuf_empty, file_v1_crypto]); + fileDesc("Cg92MS9jb25maWcucHJvdG8SAnYxIqwBCgZDb25maWcSDQoFbW9kbm8YASABKAUSDwoHdmVyc2lvbhgGIAEoBRIQCghpbnN0YW5jZRgCIAEoCRIXCgVyZXBvcxgDIAMoCzIILnYxLlJlcG8SFwoFcGxhbnMYBCADKAsyCC52MS5QbGFuEhYKBGF1dGgYBSABKAsyCC52MS5BdXRoEiYKCW11bHRpaG9zdBgHIAEoCzINLnYxLk11bHRpaG9zdFIEc3luYyL6BQoJTXVsdGlob3N0EiAKCGlkZW50aXR5GAEgASgLMg4udjEuUHJpdmF0ZUtleRInCgtrbm93bl9ob3N0cxgCIAMoCzISLnYxLk11bHRpaG9zdC5QZWVyEi4KEmF1dGhvcml6ZWRfY2xpZW50cxgDIAMoCzISLnYxLk11bHRpaG9zdC5QZWVyEjIKDnBhaXJpbmdfdG9rZW5zGAQgAygLMhoudjEuTXVsdGlob3N0LlBhaXJpbmdUb2tlbhqcAQoEUGVlchITCgtpbnN0YW5jZV9pZBgBIAEoCRIUCgVrZXlpZBgCIAEoCVIFa2V5SWQSLQoLcGVybWlzc2lvbnMYBSADKAsyGC52MS5NdWx0aWhvc3QuUGVybWlzc2lvbhIUCgxpbnN0YW5jZV91cmwYBCABKAkSHgoWaW5pdGlhbF9wYWlyaW5nX3NlY3JldBgGIAEoCUoECAMQBBquAQoMUGFpcmluZ1Rva2VuEg4KBnNlY3JldBgBIAEoCRINCgVsYWJlbBgCIAEoCRIXCg9jcmVhdGVkX2F0X3VuaXgYAyABKAMSFwoPZXhwaXJlc19hdF91bml4GAQgASgDEhAKCG1heF91c2VzGAUgASgFEgwKBHVzZXMYBiABKAUSLQoLcGVybWlzc2lvbnMYByADKAsyGC52MS5NdWx0aWhvc3QuUGVybWlzc2lvbhrtAQoKUGVybWlzc2lvbhIrCgR0eXBlGAEgASgOMh0udjEuTXVsdGlob3N0LlBlcm1pc3Npb24uVHlwZRIOCgZzY29wZXMYAiADKAkioQEKBFR5cGUSFgoSUEVSTUlTU0lPTl9VTktOT1dOEAASHgoaUEVSTUlTU0lPTl9SRUFEX09QRVJBVElPTlMQARIaChZQRVJNSVNTSU9OX1JFQURfQ09ORklHEAISIAocUEVSTUlTU0lPTl9SRUFEX1dSSVRFX0NPTkZJRxADEiMKH1BFUk1JU1NJT05fUkVDRUlWRV9TSEFSRURfUkVQT1MQBCLHAgoEUmVwbxIKCgJpZBgBIAEoCRILCgN1cmkYAiABKAkSDAoEZ3VpZBgLIAEoCRIQCghwYXNzd29yZBgDIAEoCRILCgNlbnYYBCADKAkSDQoFZmxhZ3MYBSADKAkSJQoMcHJ1bmVfcG9saWN5GAYgASgLMg8udjEuUHJ1bmVQb2xpY3kSJQoMY2hlY2tfcG9saWN5GAkgASgLMg8udjEuQ2hlY2tQb2xpY3kSFwoFaG9va3MYByADKAsyCC52MS5Ib29rEhMKC2F1dG9fdW5sb2NrGAggASgIEhcKD2F1dG9faW5pdGlhbGl6ZRgMIAEoCBIpCg5jb21tYW5kX3ByZWZpeBgKIAEoCzIRLnYxLkNvbW1hbmRQcmVmaXgSDgoGc2hhcmVkGA0gASgIEhoKEm9yaWdpbl9pbnN0YW5jZV9pZBgOIAEoCSKGAgoEUGxhbhIKCgJpZBgBIAEoCRIMCgRyZXBvGAIgASgJEg0KBXBhdGhzGAQgAygJEhAKCGV4Y2x1ZGVzGAUgAygJEhEKCWlleGNsdWRlcxgJIAMoCRIeCghzY2hlZHVsZRgMIAEoCzIMLnYxLlNjaGVkdWxlEiYKCXJldGVudGlvbhgHIAEoCzITLnYxLlJldGVudGlvblBvbGljeRIXCgVob29rcxgIIAMoCzIILnYxLkhvb2sSIgoMYmFja3VwX2ZsYWdzGAogAygJUgxiYWNrdXBfZmxhZ3MSGQoRc2tpcF9pZl91bmNoYW5nZWQYDSABKAhKBAgDEARKBAgGEAdKBAgLEAwiigIKDUNvbW1hbmRQcmVmaXgSLgoHaW9fbmljZRgBIAEoDjIdLnYxLkNvbW1hbmRQcmVmaXguSU9OaWNlTGV2ZWwSMAoIY3B1X25pY2UYAiABKA4yHi52MS5Db21tYW5kUHJlZml4LkNQVU5pY2VMZXZlbCJbCgtJT05pY2VMZXZlbBIOCgpJT19ERUZBVUxUEAASFgoSSU9fQkVTVF9FRkZPUlRfTE9XEAESFwoTSU9fQkVTVF9FRkZPUlRfSElHSBACEgsKB0lPX0lETEUQAyI6CgxDUFVOaWNlTGV2ZWwSDwoLQ1BVX0RFRkFVTFQQABIMCghDUFVfSElHSBABEgsKB0NQVV9MT1cQAiKXAgoPUmV0ZW50aW9uUG9saWN5EhwKEnBvbGljeV9rZWVwX2xhc3RfbhgKIAEoBUgAEkYKFHBvbGljeV90aW1lX2J1Y2tldGVkGAsgASgLMiYudjEuUmV0ZW50aW9uUG9saWN5LlRpbWVCdWNrZXRlZENvdW50c0gAEhkKD3BvbGljeV9rZWVwX2FsbBgMIAEoCEgAGnkKElRpbWVCdWNrZXRlZENvdW50cxIOCgZob3VybHkYASABKAUSDQoFZGFpbHkYAiABKAUSDgoGd2Vla2x5GAMgASgFEg8KB21vbnRobHkYBCABKAUSDgoGeWVhcmx5GAUgASgFEhMKC2tlZXBfbGFzdF9uGAYgASgFQggKBnBvbGljeSJjCgtQcnVuZVBvbGljeRIeCghzY2hlZHVsZRgCIAEoCzIMLnYxLlNjaGVkdWxlEhgKEG1heF91bnVzZWRfYnl0ZXMYAyABKAMSGgoSbWF4X3VudXNlZF9wZXJjZW50GAQgASgBInMKC0NoZWNrUG9saWN5Eh4KCHNjaGVkdWxlGAEgASgLMgwudjEuU2NoZWR1bGUSGAoOc3RydWN0dXJlX29ubHkYZCABKAhIABIiChhyZWFkX2RhdGFfc3Vic2V0X3BlcmNlbnQYZSABKAFIAEIGCgRtb2RlIusBCghTY2hlZHVsZRISCghkaXNhYmxlZBgBIAEoCEgAEg4KBGNyb24YAiABKAlIABIaChBtYXhGcmVxdWVuY3lEYXlzGAMgASgFSAASGwoRbWF4RnJlcXVlbmN5SG91cnMYBCABKAVIABIhCgVjbG9jaxgFIAEoDjISLnYxLlNjaGVkdWxlLkNsb2NrIlMKBUNsb2NrEhEKDUNMT0NLX0RFRkFVTFQQABIPCgtDTE9DS19MT0NBTBABEg0KCUNMT0NLX1VUQxACEhcKE0NMT0NLX0xBU1RfUlVOX1RJTUUQA0IKCghzY2hlZHVsZSKADQoESG9vaxImCgpjb25kaXRpb25zGAEgAygOMhIudjEuSG9vay5Db25kaXRpb24SIgoIb25fZXJyb3IYAiABKA4yEC52MS5Ib29rLk9uRXJyb3ISKgoOYWN0aW9uX2NvbW1hbmQYZCABKAsyEC52MS5Ib29rLkNvbW1hbmRIABIqCg5hY3Rpb25fd2ViaG9vaxhlIAEoCzIQLnYxLkhvb2suV2ViaG9va0gAEioKDmFjdGlvbl9kaXNjb3JkGGYgASgLMhAudjEuSG9vay5EaXNjb3JkSAASKAoNYWN0aW9uX2dvdGlmeRhnIAEoCzIPLnYxLkhvb2suR290aWZ5SAASJgoMYWN0aW9uX3NsYWNrGGggASgLMg4udjEuSG9vay5TbGFja0gAEiwKD2FjdGlvbl9zaG91dHJychhpIAEoCzIRLnYxLkhvb2suU2hvdXRycnJIABI0ChNhY3Rpb25faGVhbHRoY2hlY2tzGGogASgLMhUudjEuSG9vay5IZWFsdGhjaGVja3NIABIsCg9hY3Rpb25fdGVsZWdyYW0YayABKAsyES52MS5Ib29rLlRlbGVncmFtSAAaGgoHQ29tbWFuZBIPCgdjb21tYW5kGAEgASgJGoMBCgdXZWJob29rEhMKC3dlYmhvb2tfdXJsGAEgASgJEicKBm1ldGhvZBgCIAEoDjIXLnYxLkhvb2suV2ViaG9vay5NZXRob2QSEAoIdGVtcGxhdGUYZCABKAkiKAoGTWV0aG9kEgsKB1VOS05PV04QABIHCgNHRVQQARIICgRQT1NUEAIaMAoHRGlzY29yZBITCgt3ZWJob29rX3VybBgBIAEoCRIQCgh0ZW1wbGF0ZRgCIAEoCRplCgZHb3RpZnkSEAoIYmFzZV91cmwYASABKAkSDQoFdG9rZW4YAyABKAkSEAoIdGVtcGxhdGUYZCABKAkSFgoOdGl0bGVfdGVtcGxhdGUYZSABKAkSEAoIcHJpb3JpdHkYZiABKAUaLgoFU2xhY2sSEwoLd2ViaG9va191cmwYASABKAkSEAoIdGVtcGxhdGUYAiABKAkaMgoIU2hvdXRycnISFAoMc2hvdXRycnJfdXJsGAEgASgJEhAKCHRlbXBsYXRlGAIgASgJGjUKDEhlYWx0aGNoZWNrcxITCgt3ZWJob29rX3VybBgBIAEoCRIQCgh0ZW1wbGF0ZRgCIAEoCRpACghUZWxlZ3JhbRIRCglib3RfdG9rZW4YASABKAkSDwoHY2hhdF9pZBgCIAEoCRIQCgh0ZW1wbGF0ZRgDIAEoCSL1AwoJQ29uZGl0aW9uEhUKEUNPTkRJVElPTl9VTktOT1dOEAASFwoTQ09ORElUSU9OX0FOWV9FUlJPUhABEhwKGENPTkRJVElPTl9TTkFQU0hPVF9TVEFSVBACEhoKFkNPTkRJVElPTl9TTkFQU0hPVF9FTkQQAxIcChhDT05ESVRJT05fU05BUFNIT1RfRVJST1IQBBIeChpDT05ESVRJT05fU05BUFNIT1RfV0FSTklORxAFEh4KGkNPTkRJVElPTl9TTkFQU0hPVF9TVUNDRVNTEAYSHgoaQ09ORElUSU9OX1NOQVBTSE9UX1NLSVBQRUQQBxIZChVDT05ESVRJT05fUFJVTkVfU1RBUlQQZBIZChVDT05ESVRJT05fUFJVTkVfRVJST1IQZRIbChdDT05ESVRJT05fUFJVTkVfU1VDQ0VTUxBmEhoKFUNPTkRJVElPTl9DSEVDS19TVEFSVBDIARIaChVDT05ESVRJT05fQ0hFQ0tfRVJST1IQyQESHAoXQ09ORElUSU9OX0NIRUNLX1NVQ0NFU1MQygESGwoWQ09ORElUSU9OX0ZPUkdFVF9TVEFSVBCsAhIbChZDT05ESVRJT05fRk9SR0VUX0VSUk9SEK0CEh0KGENPTkRJVElPTl9GT1JHRVRfU1VDQ0VTUxCuAiKpAQoHT25FcnJvchITCg9PTl9FUlJPUl9JR05PUkUQABITCg9PTl9FUlJPUl9DQU5DRUwQARISCg5PTl9FUlJPUl9GQVRBTBACEhoKFk9OX0VSUk9SX1JFVFJZXzFNSU5VVEUQZBIcChhPTl9FUlJPUl9SRVRSWV8xME1JTlVURVMQZRImCiJPTl9FUlJPUl9SRVRSWV9FWFBPTkVOVElBTF9CQUNLT0ZGEGdCCAoGYWN0aW9uIjEKBEF1dGgSEAoIZGlzYWJsZWQYASABKAgSFwoFdXNlcnMYAiADKAsyCC52MS5Vc2VyIjsKBFVzZXISDAoEbmFtZRgBIAEoCRIZCg9wYXNzd29yZF9iY3J5cHQYAiABKAlIAEIKCghwYXNzd29yZEIsWipnaXRodWIuY29tL2dhcmV0aGdlb3JnZS9iYWNrcmVzdC9nZW4vZ28vdjFiBnByb3RvMw", [file_google_protobuf_empty, file_v1_crypto]); /** * Config is the top level config object for restic UI. @@ -276,6 +276,14 @@ export enum Multihost_Permission_Type { * @generated from enum value: PERMISSION_READ_WRITE_CONFIG = 3; */ PERMISSION_READ_WRITE_CONFIG = 3, + + /** + * When granted to an authorizedClient, the server will push repos marked as 'shared' to the client. + * This permission does not use scopes — if present, all shared repos are pushed. + * + * @generated from enum value: PERMISSION_RECEIVE_SHARED_REPOS = 4; + */ + PERMISSION_RECEIVE_SHARED_REPOS = 4, } /** diff --git a/webui/messages/en.json b/webui/messages/en.json index b249e1b9..b59e1ef5 100644 --- a/webui/messages/en.json +++ b/webui/messages/en.json @@ -83,9 +83,9 @@ "settings_multihost_identity_tooltip": "Multihost identity is used to identify this instance in a multihost setup. It is cryptographically derived from the public key of this instance.", "settings_multihost_identity_placeholder": "Unique multihost identity", "button_copy": "copy", - "settings_multihost_authorized_clients": "Authorized Clients", - "settings_multihost_authorized_clients_tooltip": "Authorized clients are other Backrest instances that are allowed to access repositories on this instance.", - "settings_multihost_authorized_client_item": "Authorized Client", + "settings_multihost_authorized_clients": "Trusted Peers", + "settings_multihost_authorized_clients_tooltip": "Trusted peers are other Backrest instances that are allowed to connect and access repositories on this instance. Peers are added automatically via pairing tokens.", + "settings_multihost_authorized_client_item": "Trusted Peer", "settings_multihost_known_hosts": "Known Hosts", "settings_multihost_known_hosts_tooltip": "Known hosts are other Backrest instances that this instance can connect to.", "settings_multihost_known_host_item": "Known Host", diff --git a/webui/src/components/common/FormModal.tsx b/webui/src/components/common/FormModal.tsx index fa87fe51..c748af86 100644 --- a/webui/src/components/common/FormModal.tsx +++ b/webui/src/components/common/FormModal.tsx @@ -61,6 +61,7 @@ export const FormModal: React.FC = ({ !e.open && onClose()} + closeOnInteractOutside={false} size={rootSize} scrollBehavior="inside" > diff --git a/webui/src/components/common/SectionCard.tsx b/webui/src/components/common/SectionCard.tsx new file mode 100644 index 00000000..b60b50b3 --- /dev/null +++ b/webui/src/components/common/SectionCard.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { Box, Flex, Text } from "@chakra-ui/react"; +import { IconType } from "react-icons"; + +interface SectionCardProps { + id?: string; + icon?: React.ReactElement | IconType; + title: string; + description?: string; + children: React.ReactNode; + cardRef?: React.Ref; +} + +export const SectionCard: React.FC = ({ + id, + icon, + title, + description, + children, + cardRef, +}) => { + return ( + + + {icon && ( + + {React.isValidElement(icon) + ? icon + : React.createElement(icon as IconType, { size: 16 })} + + )} + + + {title} + + {description && ( + + {description} + + )} + + + {children} + + ); +}; diff --git a/webui/src/components/common/StatusPill.tsx b/webui/src/components/common/StatusPill.tsx new file mode 100644 index 00000000..d7b09eba --- /dev/null +++ b/webui/src/components/common/StatusPill.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { Box } from "@chakra-ui/react"; + +type PillTone = "neutral" | "ok" | "warn" | "error" | "info" | "mono"; + +interface StatusPillProps { + tone?: PillTone; + children: React.ReactNode; +} + +const toneStyles: Record = { + neutral: { bg: "gray.100", color: "gray.700" }, + ok: { bg: "green.100", color: "green.700" }, + warn: { bg: "orange.100", color: "orange.700" }, + error: { bg: "red.100", color: "red.700" }, + info: { bg: "blue.100", color: "blue.700" }, + mono: { bg: "gray.100", color: "gray.900" }, +}; + +export const StatusPill: React.FC = ({ + tone = "neutral", + children, +}) => { + const styles = toneStyles[tone]; + return ( + + {children} + + ); +}; diff --git a/webui/src/components/common/ToggleField.tsx b/webui/src/components/common/ToggleField.tsx new file mode 100644 index 00000000..94969158 --- /dev/null +++ b/webui/src/components/common/ToggleField.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { Box, Flex, Text } from "@chakra-ui/react"; + +interface ToggleFieldProps { + checked: boolean; + onChange: (checked: boolean) => void; + label: React.ReactNode; + hint?: string; + disabled?: boolean; +} + +export const ToggleField: React.FC = ({ + checked, + onChange, + label, + hint, + disabled = false, +}) => { + return ( + + {/* Track */} + { + e.preventDefault(); + if (!disabled) onChange(!checked); + }} + > + {/* Thumb */} + + + + + {label} + + {hint && ( + + {hint} + + )} + + !disabled && onChange(e.target.checked)} + style={{ display: "none" }} + /> + + ); +}; diff --git a/webui/src/components/common/TwoPaneModal.tsx b/webui/src/components/common/TwoPaneModal.tsx new file mode 100644 index 00000000..b4be3048 --- /dev/null +++ b/webui/src/components/common/TwoPaneModal.tsx @@ -0,0 +1,404 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Box, Flex, Text, Portal } from "@chakra-ui/react"; +import { + DialogBackdrop, + DialogContent, + DialogPositioner, + DialogRoot, +} from "@chakra-ui/react"; +import { Button } from "../ui/button"; +import { IconType } from "react-icons"; +import { + FiAlertCircle, + FiCheck, + FiLoader, + FiX, +} from "react-icons/fi"; + +// --- Section definition --- +export interface SectionDef { + id: string; + label: string; + icon: React.ReactElement | IconType; +} + +// --- TwoPaneModal props --- +interface TwoPaneModalProps { + isOpen: boolean; + onClose: () => void; + + // Header + title: string; + subtitle?: string; + headerIcon?: React.ReactElement; + headerExtra?: React.ReactNode; + + // Sections & nav + sections: SectionDef[]; + children: React.ReactNode; + + // Save bar + dirty?: boolean; + dirtyCount?: number; + errorCount?: number; + onSave?: () => void; + onDiscard?: () => void; + saving?: boolean; + saveDisabled?: boolean; + + // Footer override (if you don't want the default save bar) + footer?: React.ReactNode; + + width?: string | number; +} + +export const TwoPaneModal: React.FC = ({ + isOpen, + onClose, + title, + subtitle, + headerIcon, + headerExtra, + sections, + children, + dirty = false, + dirtyCount = 0, + errorCount = 0, + onSave, + onDiscard, + saving = false, + saveDisabled = false, + footer, + width = "900px", +}) => { + const scrollRef = useRef(null); + const sectionRefs = useRef>({}); + const [activeSection, setActiveSection] = useState(sections[0]?.id || ""); + + // Scroll-spy + useEffect(() => { + const root = scrollRef.current; + if (!root) return; + const onScroll = () => { + const top = root.scrollTop; + let current = sections[0]?.id || ""; + for (const s of sections) { + const el = sectionRefs.current[s.id]; + if (!el) continue; + if (el.offsetTop - 80 <= top) current = s.id; + } + setActiveSection(current); + }; + root.addEventListener("scroll", onScroll, { passive: true }); + onScroll(); + return () => root.removeEventListener("scroll", onScroll); + }, [sections]); + + const scrollTo = useCallback( + (id: string) => { + const el = sectionRefs.current[id]; + const root = scrollRef.current; + if (!el || !root) return; + root.scrollTo({ top: el.offsetTop - 56, behavior: "smooth" }); + setActiveSection(id); + }, + [], + ); + + // Provide ref-registration function to children via context + const registerRef = useCallback((id: string, el: HTMLElement | null) => { + sectionRefs.current[id] = el; + }, []); + + return ( + !e.open && onClose()} + closeOnInteractOutside={false} + size="xl" + > + + + + + {/* Header */} + + {headerIcon && ( + + {headerIcon} + + )} + + + + {title} + + {headerExtra} + + {subtitle && ( + + {subtitle} + + )} + + + + + + + {/* Two-pane body */} + + {/* Nav rail */} + + {sections.map((s) => { + const isActive = activeSection === s.id; + return ( + scrollTo(s.id)} + display="flex" + alignItems="center" + gap={2} + w="full" + py={1.5} + px={2.5} + bg={isActive ? "bg.muted" : "transparent"} + border={0} + borderRadius="sm" + fontSize="sm" + fontWeight={isActive ? "medium" : "normal"} + color="fg" + cursor="pointer" + textAlign="left" + mb={0.5} + _hover={{ bg: isActive ? "bg.muted" : "bg.emphasized" }} + > + + {React.isValidElement(s.icon) + ? s.icon + : React.createElement(s.icon as IconType, { + size: 14, + })} + + {s.label} + + ); + })} + + + {/* Scrolling content */} + + + {children} + + + + + + {/* Footer / save bar */} + {footer ? ( + + {footer} + + ) : ( + + + {dirty ? ( + <> + + + {dirtyCount} unsaved{" "} + {dirtyCount === 1 ? "change" : "changes"} + + {errorCount > 0 && ( + + + {errorCount} {errorCount === 1 ? "error" : "errors"} to + fix + + )} + + ) : ( + <> + + + + + All changes saved + + + )} + + + + + + )} + + + + + ); +}; + +// --- Context for child sections to register refs --- +interface TwoPaneContextValue { + registerRef: (id: string, el: HTMLElement | null) => void; +} + +const TwoPaneContext = React.createContext({ + registerRef: () => {}, +}); + +export const useTwoPaneRef = () => React.useContext(TwoPaneContext); + +// --- TwoPaneSection: wraps each section's content with ref registration --- +interface TwoPaneSectionProps { + id: string; + children: React.ReactNode; +} + +export const TwoPaneSection: React.FC = ({ + id, + children, +}) => { + const { registerRef } = useTwoPaneRef(); + const ref = useCallback( + (el: HTMLDivElement | null) => { + registerRef(id, el); + }, + [id, registerRef], + ); + + return ( + + {children} + + ); +}; diff --git a/webui/src/features/plans/AddPlanModal.tsx b/webui/src/features/plans/AddPlanModal.tsx index 0ca7fff7..297d2273 100644 --- a/webui/src/features/plans/AddPlanModal.tsx +++ b/webui/src/features/plans/AddPlanModal.tsx @@ -2,30 +2,24 @@ import { Flex, Stack, Input, - Textarea, createListCollection, SelectContent, SelectItem, - SelectLabel, SelectRoot, SelectTrigger, SelectValueText, - IconButton, Card, - Box, - HStack, Text as CText, Grid, Code, } from "@chakra-ui/react"; -import { Checkbox } from "../../components/ui/checkbox"; import { AccordionItem, AccordionItemContent, AccordionItemTrigger, AccordionRoot, } from "../../components/ui/accordion"; -import React, { useEffect, useState, useMemo } from "react"; +import { useEffect, useState, useMemo } from "react"; import { useShowModal } from "../../components/common/ModalManager"; import { ConfigSchema, @@ -33,65 +27,47 @@ import { RetentionPolicySchema, Schedule_Clock, type Plan, - type RetentionPolicy, - type Schedule, } from "../../../gen/ts/v1/config_pb"; -import { FiPlus as Plus, FiMinus as Minus, FiMenu } from "react-icons/fi"; -import { BsCalculator as Calculator } from "react-icons/bs"; +import { FiFileText, FiFolder, FiClock, FiArchive, FiSliders } from "react-icons/fi"; import { alerts, formatErrorAlert } from "../../components/common/Alerts"; import { namePattern } from "../../lib/util"; import { ConfirmButton } from "../../components/common/SpinButton"; import { useConfig } from "../../app/provider"; import { backrestService } from "../../api/client"; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragEndEvent, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, - useSortable, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; import { clone, create, equals, fromJson, toJson, - JsonValue, } from "@bufbuild/protobuf"; import * as m from "../../paraglide/messages"; -import { FormModal } from "../../components/common/FormModal"; import { Button } from "../../components/ui/button"; import { Field } from "../../components/ui/field"; import { Tooltip } from "../../components/ui/tooltip"; -import { NumberInputField } from "../../components/common/NumberInput"; // Assuming I migrated this or will check +import { NumberInputField } from "../../components/common/NumberInput"; import { ScheduleFormItem, ScheduleDefaultsDaily, } from "../../components/common/ScheduleFormItem"; -// Use the real implementation -import { URIAutocomplete } from "../../components/common/URIAutocomplete"; import { HooksFormList, hooksListTooltipText, } from "../../components/common/HooksFormList"; import { DynamicList } from "../../components/common/DynamicList"; +import { + TwoPaneModal, + TwoPaneSection, + type SectionDef, +} from "../../components/common/TwoPaneModal"; +import { SectionCard } from "../../components/common/SectionCard"; // Default Plan const planDefaults = create(PlanSchema, { schedule: { schedule: { case: "cron", - value: "0 * * * *", // every hour + value: "0 * * * *", }, clock: Schedule_Clock.LOCAL, }, @@ -112,14 +88,12 @@ export const AddPlanModal = ({ template, onSaveOverride }: { template: Plan | nu const showModal = useShowModal(); const [config, setConfig] = useConfig(); - // Local State const [formData, setFormData] = useState( template ? toJson(PlanSchema, template, { alwaysEmitImplicit: true }) : toJson(PlanSchema, planDefaults, { alwaysEmitImplicit: true }), ); - // Sync state with template prop useEffect(() => { setFormData( template @@ -128,13 +102,11 @@ export const AddPlanModal = ({ template, onSaveOverride }: { template: Plan | nu ); }, [template]); - // Helper to update fields const updateField = (path: string[], value: any) => { setFormData((prev: any) => { const next = { ...prev }; let curr = next; for (let i = 0; i < path.length - 1; i++) { - // Create shallow copy of the next level if it exists, or new object if not curr[path[i]] = curr[path[i]] ? { ...curr[path[i]] } : {}; curr = curr[path[i]]; } @@ -181,7 +153,6 @@ export const AddPlanModal = ({ template, onSaveOverride }: { template: Plan | nu const handleOk = async () => { setConfirmLoading(true); try { - // Validation if (!formData.id?.trim()) { throw new Error(m.add_plan_modal_validation_plan_name_required()); } @@ -201,7 +172,6 @@ export const AddPlanModal = ({ template, onSaveOverride }: { template: Plan | nu throw new Error(m.add_plan_modal_validation_flag_pattern()); } - // Check retention for sub-hourly schedules const scheduleValue = formData.schedule?.schedule?.value; const isCron = formData.schedule?.schedule?.case === "cron"; const isSubHourly = @@ -221,7 +191,6 @@ export const AddPlanModal = ({ template, onSaveOverride }: { template: Plan | nu ignoreUnknownFields: true, }); - // Clean up retention if empty (logic from original) if ( plan.retention && equals( @@ -265,8 +234,40 @@ export const AddPlanModal = ({ template, onSaveOverride }: { template: Plan | nu items: repos.map((r) => ({ label: r.id, value: r.id })), }); + const sections: SectionDef[] = [ + { id: "details", label: "Details", icon: }, + { id: "scope", label: "Scope", icon: }, + { id: "schedule", label: "Schedule", icon: }, + { id: "retention", label: "Retention", icon: }, + { id: "advanced", label: "Advanced", icon: }, + ]; + + const footer = ( + + + {template && ( + + {m.add_plan_modal_button_delete()} + + )} + + + ); + return ( - showModal(null)} title={ @@ -274,267 +275,232 @@ export const AddPlanModal = ({ template, onSaveOverride }: { template: Plan | nu ? m.add_plan_modal_title_update() : m.add_plan_modal_title_add() } - size="large" - footer={ - - - {template && ( - - {m.add_plan_modal_button_delete()} - - )} - - - } + headerIcon={} + sections={sections} + footer={footer} > - - {/* Info Link */} -

- {m.add_plan_modal_see_guide_prefix()}{" "} - - {m.add_plan_modal_see_guide_link()} - {" "} - {m.add_plan_modal_see_guide_suffix()} -

+ {/* Details Section */} + + } + title={m.op_row_backup_details()} + description="Plan name and target repository." + > + + p.id === formData.id))) + } + errorText={ + !!formData.id && !namePattern.test(formData.id) + ? m.add_plan_modal_validation_plan_name_pattern() + : m.add_plan_modal_validation_plan_exists() + } + > + updateField(["id"], e.target.value)} + disabled={!!template} + placeholder={"plan" + ((config?.plans?.length || 0) + 1)} + /> + - {/* Plan Details */} -
- - - - p.id === formData.id))) - } - errorText={ - !!formData.id && !namePattern.test(formData.id) - ? m.add_plan_modal_validation_plan_name_pattern() - : m.add_plan_modal_validation_plan_exists() - } - > - updateField(["id"], e.target.value)} - disabled={!!template} - placeholder={"plan" + ((config?.plans?.length || 0) + 1)} - /> - + + + updateField(["repo"], e.value[0]) + } + disabled={!!template} + width="full" + > + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + {repoOptions.items.map((item: any) => ( + + {item.label} + + ))} + + + + + + - - - updateField(["repo"], e.value[0]) - } - disabled={!!template} - width="full" + {/* Scope Section */} + + } + title={m.settings_peer_permission_scopes()} + description="Directories and exclusion patterns." + > + + updateField(["paths"], items)} + required + autocompleteType="uri" + placeholder={m.add_plan_modal_field_paths()} + /> + + + updateField(["excludes"], items) + } + tooltip={ + <> + {m.add_plan_modal_field_excludes_tooltip_prefix()}{" "} + - {/* @ts-ignore */} - - {/* @ts-ignore */} - - - {/* @ts-ignore */} - - {repoOptions.items.map((item: any) => ( - // @ts-ignore - - {item.label} - - ))} - - - - - - -
+ {m.add_plan_modal_field_excludes_tooltip_link()} + {" "} + {m.add_plan_modal_field_excludes_tooltip_suffix()} + + } + placeholder={m.add_plan_modal_field_excludes()} + /> - {/* Scope */} -
- - - - updateField(["paths"], items)} - required - autocompleteType="uri" - placeholder={m.add_plan_modal_field_paths()} - /> + + updateField(["iexcludes"], items) + } + tooltip={ + <> + {m.add_plan_modal_field_iexcludes_tooltip_prefix()}{" "} + + {m.add_plan_modal_field_excludes_tooltip_link()} + {" "} + {m.add_plan_modal_field_excludes_tooltip_suffix()} + + } + placeholder={m.add_plan_modal_field_iexcludes()} + /> + + + - - updateField(["excludes"], items) - } - tooltip={ - <> - {m.add_plan_modal_field_excludes_tooltip_prefix()}{" "} - - {m.add_plan_modal_field_excludes_tooltip_link()} - {" "} - {m.add_plan_modal_field_excludes_tooltip_suffix()} - - } - placeholder={m.add_plan_modal_field_excludes()} - /> - - - updateField(["iexcludes"], items) - } - tooltip={ - <> - {m.add_plan_modal_field_iexcludes_tooltip_prefix()}{" "} - - {m.add_plan_modal_field_excludes_tooltip_link()} - {" "} - {m.add_plan_modal_field_excludes_tooltip_suffix()} - - } - placeholder={m.add_plan_modal_field_iexcludes()} - /> - - - -
- - {/* Schedule */} -
+ {/* Schedule Section */} + + } + title={m.add_plan_modal_field_schedule()} + description="When backups run automatically." + > updateField(["schedule"], v)} defaults={ScheduleDefaultsDaily} /> -
+
+
- {/* Retention Policy */} -
+ {/* Retention Section */} + + } + title={m.add_plan_modal_retention_policy_label()} + description="How long to keep snapshots before forgetting them." + > updateField(["retention"], v)} /> -
+ + - {/* Advanced */} -
- - - - - updateField(["backup_flags"], items) - } - tooltip={m.add_plan_modal_field_backup_flags_tooltip()} - placeholder="--flag" - autocompleteType="flag" - /> + {/* Advanced Section */} + + } + title={m.add_plan_modal_advanced_label()} + description="Extra flags and notification hooks." + > + + + updateField(["backup_flags"], items) + } + tooltip={m.add_plan_modal_field_backup_flags_tooltip()} + placeholder="--flag" + autocompleteType="flag" + /> - - updateField(["hooks"], v)} - /> - - - - -
+ + updateField(["hooks"], v)} + /> + +
+ + - {/* JSON Preview */} - - - - - {m.add_repo_modal_preview_json()} - - - - - {JSON.stringify(formData, null, 2)} - - - - - -
+ {/* JSON Preview */} + + + + + {m.add_repo_modal_preview_json()} + + + + + {JSON.stringify(formData, null, 2)} + + + + + ); }; -const Section = ({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) => ( - - - {title} - - {children} - -); - // Retention View const RetentionPolicyView = ({ schedule, retention, onChange }: any) => { - // Mode determination const determineMode = () => { if (!retention) return "policyTimeBucketed"; if (retention.policyKeepLastN) return "policyKeepLastN"; @@ -563,7 +529,6 @@ const RetentionPolicyView = ({ schedule, retention, onChange }: any) => { } }; - // Derived values const cronIsSubHourly = useMemo( () => schedule?.schedule?.value && @@ -572,12 +537,10 @@ const RetentionPolicyView = ({ schedule, retention, onChange }: any) => { [schedule], ); - // Helpers to update nested retention fields const updateRetentionField = (path: string[], val: any) => { const next = { ...retention }; let curr = next; for (let i = 0; i < path.length - 1; i++) { - // Create shallow copy of the next level to ensure immutability curr[path[i]] = curr[path[i]] ? { ...curr[path[i]] } : {}; curr = curr[path[i]]; } @@ -589,7 +552,6 @@ const RetentionPolicyView = ({ schedule, retention, onChange }: any) => { - {/* Mode Selector */} {[ { @@ -622,7 +584,6 @@ const RetentionPolicyView = ({ schedule, retention, onChange }: any) => { - {/* Mode Content */} {mode === "policyKeepAll" && (

{m.add_plan_modal_retention_policy_keep_all_warning()} diff --git a/webui/src/features/repositories/AddRepoModal.tsx b/webui/src/features/repositories/AddRepoModal.tsx index fa02fea2..384ca65e 100644 --- a/webui/src/features/repositories/AddRepoModal.tsx +++ b/webui/src/features/repositories/AddRepoModal.tsx @@ -2,28 +2,18 @@ import { Stack, Flex, Input, - Card, Text as CText, Grid, Code, Box, } from "@chakra-ui/react"; import { EnumSelector, EnumOption } from "../../components/common/EnumSelector"; -import { Checkbox } from "../../components/ui/checkbox"; -import { - AccordionItem, - AccordionItemContent, - AccordionItemTrigger, - AccordionRoot, -} from "../../components/ui/accordion"; import React, { useEffect, useRef, useState } from "react"; import { useShowModal } from "../../components/common/ModalManager"; import { CommandPrefix_CPUNiceLevel, - CommandPrefix_CPUNiceLevelSchema, CommandPrefix_IONiceLevel, - CommandPrefix_IONiceLevelSchema, Repo, RepoSchema, Schedule_Clock, @@ -31,27 +21,24 @@ import { import { AddRepoRequestSchema, CheckRepoExistsRequestSchema, - SetupSftpRequestSchema, } from "../../../gen/ts/v1/service_pb"; import { StringValueSchema } from "../../../gen/ts/types/value_pb"; import { URIAutocomplete } from "../../components/common/URIAutocomplete"; import { alerts, formatErrorAlert } from "../../components/common/Alerts"; import { namePattern } from "../../lib/util"; import { backrestService } from "../../api/client"; -import { ConfirmButton, SpinButton } from "../../components/common/SpinButton"; +import { ConfirmButton } from "../../components/common/SpinButton"; import { useConfig } from "../../app/provider"; import { ScheduleFormItem, ScheduleDefaultsInfrequent, } from "../../components/common/ScheduleFormItem"; import { isWindows } from "../../state/buildcfg"; -import { create, fromJson, toJson, JsonValue } from "@bufbuild/protobuf"; +import { create, fromJson, toJson } from "@bufbuild/protobuf"; import * as m from "../../paraglide/messages"; -import { FormModal } from "../../components/common/FormModal"; import { Button } from "../../components/ui/button"; import { Field } from "../../components/ui/field"; import { PasswordInput } from "../../components/ui/password-input"; -import { Tooltip } from "../../components/ui/tooltip"; import { NumberInputField } from "../../components/common/NumberInput"; import { HooksFormList, @@ -68,6 +55,26 @@ import { DialogRoot, DialogTitle, } from "../../components/ui/dialog"; +import { + FiTag, + FiLink, + FiClock, + FiZap, + FiSliders, +} from "react-icons/fi"; +import { + TwoPaneModal, + TwoPaneSection, + type SectionDef, +} from "../../components/common/TwoPaneModal"; +import { SectionCard } from "../../components/common/SectionCard"; +import { ToggleField } from "../../components/common/ToggleField"; +import { + AccordionRoot, + AccordionItem, + AccordionItemTrigger, + AccordionItemContent, +} from "../../components/ui/accordion"; const repoDefaults = create(RepoSchema, { prunePolicy: { @@ -75,7 +82,7 @@ const repoDefaults = create(RepoSchema, { schedule: { schedule: { case: "cron", - value: "0 0 1 * *", // 1st of the month + value: "0 0 1 * *", }, clock: Schedule_Clock.LAST_RUN_TIME, }, @@ -84,7 +91,7 @@ const repoDefaults = create(RepoSchema, { schedule: { schedule: { case: "cron", - value: "0 0 1 * *", // 1st of the month + value: "0 0 1 * *", }, clock: Schedule_Clock.LAST_RUN_TIME, }, @@ -137,7 +144,6 @@ const SftpConfigSection = ({ try { if (!uri) return; - // Parse host and port from the SFTP URI const authority = uri.replace("sftp:", "").split("/")[0]; const hostPart = authority.includes("@") ? authority.split("@")[1] : authority; let host = hostPart; @@ -284,19 +290,16 @@ export const AddRepoModal = ({ template, onSaveOverride }: { template: Repo | nu const [config, setConfig] = useConfig(); const isRemoteOrigin = !!template?.originInstanceId; - // Local state for form fields const [formData, setFormData] = useState( template ? toJson(RepoSchema, template, { alwaysEmitImplicit: true }) : toJson(RepoSchema, repoDefaults, { alwaysEmitImplicit: true }), ); - // SFTP specific state const [sftpIdentityFile, setSftpIdentityFile] = useState(""); const [sftpPort, setSftpPort] = useState(null); const [sftpKnownHostsPath, setSftpKnownHostsPath] = useState(""); - // Ref to read current flags without making them a useEffect dependency const flagsRef = useRef([]); const [confirmation, setConfirmation] = useState({ @@ -318,7 +321,6 @@ export const AddRepoModal = ({ template, onSaveOverride }: { template: Repo | nu setSftpKnownHostsPath(""); if (template?.uri?.startsWith("sftp:")) { - // Populate SFTP fields by parsing the existing sftp.args flag const sftpArgsFlag = (template.flags || []).find( (f) => f.includes("sftp.args") || f.includes("sftp.command"), ); @@ -361,26 +363,20 @@ export const AddRepoModal = ({ template, onSaveOverride }: { template: Repo | nu return curr; }; - // Keep flagsRef in sync with latest formData.flags so the SFTP effect can - // read the current value without flags being a reactive dependency. flagsRef.current = (formData.flags as string[]) || []; - // Keep sftp.args flag in sync with the SFTP config fields. useEffect(() => { const uri = getField(["uri"]); if (!uri?.startsWith("sftp:")) { return; } - // Read flags via ref so this effect does not re-run whenever the user - // edits the flags list (which would immediately erase empty rows). const currentFlags = flagsRef.current; const newFlags = currentFlags.filter( (f: string) => f && !f.includes("sftp.args") && !f.includes("sftp.command"), ); - // Always include -oBatchMode=yes; quote paths to handle spaces. let sftpArgs = "-oBatchMode=yes"; if (sftpIdentityFile) { @@ -412,8 +408,6 @@ export const AddRepoModal = ({ template, onSaveOverride }: { template: Repo | nu sftpIdentityFile, sftpPort, sftpKnownHostsPath, - // flags intentionally omitted: flagsRef avoids a circular dep where any - // user edit to flags would re-trigger the effect and erase empty rows. ]); if (!config) return null; @@ -435,10 +429,8 @@ export const AddRepoModal = ({ template, onSaveOverride }: { template: Repo | nu throw new Error(m.add_repo_modal_error_uri_required()); } - // Env and Password validation await envVarSetValidator(formData); - // Flags validation const flags = getField(["flags"]); if (flags && flags.some((f: string) => !/^\-\-?.*$/.test(f))) { throw new Error(m.add_repo_modal_error_flag_format()); @@ -532,7 +524,7 @@ export const AddRepoModal = ({ template, onSaveOverride }: { template: Repo | nu }, }); } else { - throw e; // rethrow to be caught by the outer catch + throw e; } } } catch (e: any) { @@ -643,6 +635,49 @@ export const AddRepoModal = ({ template, onSaveOverride }: { template: Repo | nu }, ]; + const sections: SectionDef[] = [ + { id: "identity", label: "Identity", icon: }, + { id: "connection", label: "Connection", icon: }, + { id: "scheduling", label: "Scheduling", icon: }, + { id: "hooks", label: "Hooks", icon: }, + { id: "advanced", label: "Advanced", icon: }, + ]; + + const footer = ( + + + {template && ( + + {m.add_plan_modal_button_delete()} + + )} + {!isRemoteOrigin && ( + <> + + + + )} + + ); + return ( <> - showModal(null)} title={ @@ -674,265 +709,198 @@ export const AddRepoModal = ({ template, onSaveOverride }: { template: Repo | nu ? m.add_repo_modal_title_edit() : m.add_repo_modal_title_add() } - size="large" - footer={ - - - {template && ( - - {m.add_plan_modal_button_delete()} - - )} - {!isRemoteOrigin && ( - <> - - - - )} - - } + headerIcon={} + sections={sections} + footer={footer} > - + {isRemoteOrigin && ( - + This repository is managed by remote instance {template?.originInstanceId} and cannot be edited. You may delete it to remove the local copy. )} -

- {m.add_repo_modal_guide_text_p1()}{" "} - - {m.add_repo_modal_guide_link_text()} - {" "} - {m.add_repo_modal_guide_text_p2()}{" "} - - {m.add_repo_modal_guide_restic_link_text()} - {" "} - {m.add_repo_modal_guide_text_p3()} -

-
- - - - + } + title="Identity" + description="Display name, identifiers, and unlock behaviour." + > + + r.id === getField(["id"]), + ))) + } + errorText={ + !!getField(["id"]) && !namePattern.test(getField(["id"])) + ? m.add_plan_modal_validation_plan_name_pattern() + : m.add_repo_modal_error_repo_exists() + } + > + ) => + updateField(["id"], e.target.value) } - required - invalid={ - !!getField(["id"]) && - (!namePattern.test(getField(["id"])) || - (!template && - !!config.repos.find( - (r) => r.id === getField(["id"]), - ))) - } - errorText={ - !!getField(["id"]) && !namePattern.test(getField(["id"])) - ? m.add_plan_modal_validation_plan_name_pattern() - : m.add_repo_modal_error_repo_exists() - } - > - ) => - updateField(["id"], e.target.value) - } - disabled={!!template} - placeholder={"repo" + ((config?.repos?.length || 0) + 1)} - /> - + disabled={!!template} + placeholder={"repo" + ((config?.repos?.length || 0) + 1)} + /> + - updateField(["autoUnlock"], v)} + label={m.add_repo_modal_field_auto_unlock()} + hint={m.add_repo_modal_field_auto_unlock_tooltip()} + /> + + updateField(["shared"], v)} + label="Shared" + hint="Automatically push this repo's configuration to all authorized clients with read permission." + /> + + + + + {/* Connection Section */} + + } + title="Connection" + description="Where the repo lives and how Backrest authenticates." + > + + + {m.add_repo_modal_field_uri_tooltip_title()} + +
  • {m.add_repo_modal_field_uri_tooltip_local()}
  • +
  • {m.add_repo_modal_field_uri_tooltip_s3()}
  • +
  • {m.add_repo_modal_field_uri_tooltip_sftp()}
  • +
  • + {m.add_repo_modal_field_uri_tooltip_see()}{" "} + + {m.add_repo_modal_field_uri_tooltip_restic_docs()} + {" "} + {m.add_repo_modal_field_uri_tooltip_info()} +
  • +
    + + } + required + > + updateField(["uri"], val)} + /> +
    + + {getField(["uri"])?.startsWith("sftp:") && ( + + )} + + - {m.add_repo_modal_field_uri_tooltip_title()} + {m.add_repo_modal_field_password_tooltip_intro()} -
  • {m.add_repo_modal_field_uri_tooltip_local()}
  • -
  • {m.add_repo_modal_field_uri_tooltip_s3()}
  • -
  • {m.add_repo_modal_field_uri_tooltip_sftp()}
  • - {m.add_repo_modal_field_uri_tooltip_see()}{" "} - - {m.add_repo_modal_field_uri_tooltip_restic_docs()} - {" "} - {m.add_repo_modal_field_uri_tooltip_info()} + {m.add_repo_modal_field_password_tooltip_entropy()} +
  • +
  • + {m.add_repo_modal_field_password_tooltip_env()} +
  • +
  • + {m.add_repo_modal_field_password_tooltip_generate()}
  • - } - required - > - updateField(["uri"], val)} - /> -
    + ) : undefined + } + > + + + ) => + updateField(["password"], e.target.value) + } + disabled={!!template} + /> + + {!template && ( + + )} + + - {/* SFTP Specific Fields */} - {getField(["uri"])?.startsWith("sftp:") && ( - - )} + updateField(["env"], items)} + tooltip={ + + + {m.add_repo_modal_field_env_vars_tooltip()} + + + + } + placeholder="KEY=VALUE" + /> +
    +
    +
    - - {m.add_repo_modal_field_password_tooltip_intro()} - -
  • - {m.add_repo_modal_field_password_tooltip_entropy()} -
  • -
  • - {m.add_repo_modal_field_password_tooltip_env()} -
  • -
  • - {m.add_repo_modal_field_password_tooltip_generate()} -
  • -
    - - ) : undefined - } - > - - - ) => - updateField(["password"], e.target.value) - } - disabled={!!template} - /> - - {!template && ( - - )} - -
    - - - updateField(["autoUnlock"], !!e.checked)} - > - {m.add_repo_modal_field_auto_unlock()} - - - {m.add_repo_modal_field_auto_unlock_tooltip()} - - - - - updateField(["shared"], !!e.checked)} - > - Shared - - - Automatically push this repo's configuration to all authorized clients with read permission. - - - -
    -
    -
    - -
    - - - - updateField(["env"], items)} - tooltip={ - - - {m.add_repo_modal_field_env_vars_tooltip()} - - - - } - placeholder="KEY=VALUE" - /> - - - updateField(["flags"], items) - } - placeholder="--flag" - /> - - - -
    -
    - - + {/* Scheduling Section */} + + } + title="Prune Policy" + description={m.add_repo_modal_field_prune_policy_help()} + > - - -
    + -
    - - + } + title="Check Policy" + description={m.add_repo_modal_field_check_policy_help()} + > - - -
    + + -
    - - + {/* Hooks Section */} + + } + title="Hooks" + description="Run commands or send notifications on operation events." + > + + updateField(["hooks"], v)} + /> + + + + + {/* Advanced Section */} + + } + title="Advanced" + description="Command priority, extra flags, and raw restic options." + > {!isWindows && ( @@ -1038,19 +1025,17 @@ export const AddRepoModal = ({ template, onSaveOverride }: { template: Repo | nu )} - - updateField(["hooks"], v)} - /> - - - - -
    + + updateField(["flags"], items) + } + placeholder="--flag" + /> +
    + + {/* JSON Preview */} @@ -1074,36 +1059,12 @@ export const AddRepoModal = ({ template, onSaveOverride }: { template: Repo | nu - - + + ); }; -const Section = ({ - title, - help, - children, -}: { - title: React.ReactNode; - help?: React.ReactNode; - children: React.ReactNode; -}) => ( - - - - {title} - - {help && ( - - {help} - - )} - - {children} - -); - // Utils const cryptoRandomPassword = (): string => { let vals = crypto.getRandomValues(new Uint8Array(64)); @@ -1208,6 +1169,7 @@ const formatMissingEnvVars = (partialMatches: string[][]): string => { }) .join(" or "); }; + const EnvVarTooltip = ({ uri }: { uri: string }) => { if (!uri) return null; const scheme = uri.split(":")[0]; diff --git a/webui/src/features/settings/SettingsModal.tsx b/webui/src/features/settings/SettingsModal.tsx index 23fb83e4..f76a51a6 100644 --- a/webui/src/features/settings/SettingsModal.tsx +++ b/webui/src/features/settings/SettingsModal.tsx @@ -2,29 +2,28 @@ import { Flex, Stack, Input, - Textarea, createListCollection, IconButton, - Card, - Heading, Text, Box, } from "@chakra-ui/react"; -import { Checkbox } from "../../components/ui/checkbox"; -import React, { useEffect, useState, useMemo } from "react"; +import { useState } from "react"; import { useShowModal } from "../../components/common/ModalManager"; import { FiPlus as Plus, FiMinus as Minus, FiCopy as Copy, + FiEye, + FiEyeOff, + FiSettings, + FiLock, + FiGlobe, } from "react-icons/fi"; import { formatErrorAlert, alerts } from "../../components/common/Alerts"; -import { namePattern } from "../../lib/util"; import { backrestService, authenticationService } from "../../api/client"; import { clone, create, fromJson, toJson } from "@bufbuild/protobuf"; import { AuthSchema, - Config, ConfigSchema, UserSchema, MultihostSchema, @@ -32,53 +31,29 @@ import { Multihost_Permission_Type, } from "../../../gen/ts/v1/config_pb"; import { GeneratePairingTokenRequestSchema } from "../../../gen/ts/v1/service_pb"; -import { PeerState } from "../../../gen/ts/v1sync/syncservice_pb"; import { useSyncStates } from "../../state/peerStates"; import { PeerStateConnectionStatusIcon } from "../../components/common/SyncStateIcon"; import { isMultihostSyncEnabled } from "../../state/buildcfg"; import * as m from "../../paraglide/messages"; -import { FormModal } from "../../components/common/FormModal"; import { Button } from "../../components/ui/button"; import { Field } from "../../components/ui/field"; -import { Tooltip } from "../../components/ui/tooltip"; import { PasswordInput } from "../../components/ui/password-input"; -import { - AccordionRoot, - AccordionItem, - AccordionItemTrigger, - AccordionItemContent, -} from "../../components/ui/accordion"; import { SelectRoot, SelectTrigger, SelectContent, SelectItem, SelectValueText, - SelectLabel, } from "../../components/ui/select"; import { useConfig } from "../../app/provider"; - import { useUserPreferences } from "../../lib/userPreferences"; - -interface FormData { - auth: { - disabled?: boolean; - users: { - name: string; - passwordBcrypt: string; - needsBcrypt?: boolean; - isExisting?: boolean; - }[]; - }; - instance: string; - multihost: { - identity: { - keyid: string; - }; - knownHosts: any[]; - authorizedClients: any[]; - }; -} +import { + TwoPaneModal, + TwoPaneSection, + type SectionDef, +} from "../../components/common/TwoPaneModal"; +import { SectionCard } from "../../components/common/SectionCard"; +import { ToggleField } from "../../components/common/ToggleField"; export const SettingsModal = () => { const [config, setConfig] = useConfig(); @@ -94,11 +69,10 @@ export const SettingsModal = () => { const [tokenMaxUses, setTokenMaxUses] = useState(1); const [generatedToken, setGeneratedToken] = useState(""); const [generateLoading, setGenerateLoading] = useState(false); + const [initialTokenCount] = useState( + () => config?.multihost?.pairingTokens?.length || 0, + ); - // Pair with server state - const [showPairWithServer, setShowPairWithServer] = useState(false); - const [pairToken, setPairToken] = useState(""); - const [pairInstanceUrl, setPairInstanceUrl] = useState(""); // Local state initialized from config const [formData, setFormData] = useState(() => { @@ -127,6 +101,11 @@ export const SettingsModal = () => { }; }); + const [initialFormData, setInitialFormData] = useState(() => + JSON.stringify(formData), + ); + const dirty = JSON.stringify(formData) !== initialFormData; + const ttlOptions = createListCollection({ items: [ { label: "15 minutes", value: "900" }, @@ -175,48 +154,6 @@ export const SettingsModal = () => { } }; - const handlePairWithServer = () => { - try { - // Parse token format: :# - const hashIdx = pairToken.indexOf("#"); - const colonIdx = pairToken.indexOf(":"); - if (hashIdx === -1 || colonIdx === -1 || colonIdx > hashIdx) { - throw new Error( - 'Invalid token format. Expected ":#"', - ); - } - const keyId = pairToken.substring(0, colonIdx); - const secret = pairToken.substring(colonIdx + 1, hashIdx); - const instanceId = pairToken.substring(hashIdx + 1); - - if (!keyId || !secret || !instanceId) { - throw new Error("Token is missing required fields"); - } - if (!pairInstanceUrl) { - throw new Error("Instance URL is required"); - } - - const knownHosts = getField(["multihost", "knownHosts"]) || []; - updateField(["multihost", "knownHosts"], [ - ...knownHosts, - { - instanceId, - keyId, - instanceUrl: pairInstanceUrl, - initialPairingSecret: secret, - permissions: [], - }, - ]); - - // Reset form - setPairToken(""); - setPairInstanceUrl(""); - setShowPairWithServer(false); - alerts.success("Server added to known hosts. Save settings to apply."); - } catch (e: any) { - alerts.error(formatErrorAlert(e, "Failed to pair with server")); - } - }; if (!config || !formData) return null; @@ -247,7 +184,6 @@ export const SettingsModal = () => { try { const workingData = JSON.parse(JSON.stringify(formData)); - // Hash passwords if needed if (workingData.auth?.users) { for (const user of workingData.auth.users) { if (user.needsBcrypt) { @@ -261,7 +197,6 @@ export const SettingsModal = () => { } } - // Update configuration let newConfig = clone(ConfigSchema, config); newConfig.auth = fromJson(AuthSchema, workingData.auth, { ignoreUnknownFields: false, @@ -278,6 +213,7 @@ export const SettingsModal = () => { } setConfig(await backrestService.setConfig(newConfig)); + setInitialFormData(JSON.stringify(formData)); setReloadOnCancel(true); alerts.success(m.settings_success_updated()); } catch (e: any) { @@ -296,509 +232,437 @@ export const SettingsModal = () => { const users = getField(["auth", "users"]) || []; + const sections: SectionDef[] = [ + { id: "general", label: "General", icon: }, + { id: "auth", label: "Authentication", icon: }, + ...(isMultihostSyncEnabled + ? [ + { + id: "multihost", + label: "Multihost", + icon: , + } as SectionDef, + ] + : []), + ]; + return ( - - - - - } + headerIcon={} + sections={sections} + dirty={dirty} + dirtyCount={1} + onSave={handleOk} + onDiscard={() => { + setFormData(JSON.parse(initialFormData)); + }} + saving={confirmLoading} > - - {users.length === 0 && !getField(["auth", "disabled"]) && ( - - - {m.settings_initial_setup_title()} - {m.settings_initial_setup_message()} - - {m.settings_initial_setup_hint()} - - - - )} - - + } + title="General" + description="Instance identity and display preferences." > - updateField(["instance"], e.target.value)} - disabled={!!config.instance} - placeholder={m.settings_field_instance_id_placeholder()} - /> - + + {users.length === 0 && !getField(["auth", "disabled"]) && ( + + + {m.settings_initial_setup_title()} + {m.settings_initial_setup_message()} + + {m.settings_initial_setup_hint()} + + + + )} - {/* @ts-ignore */} - - {/* User Settings Section */} - {/* @ts-ignore */} - - - { - // @ts-ignore - m.settings_section_user_settings - ? m.settings_section_user_settings() - : "User Settings" - } - - - - + + updateField(["instance"], e.target.value)} + disabled={!!config.instance} + placeholder={m.settings_field_instance_id_placeholder()} + /> + + + + + + + + {/* Authentication Section */} + + } + title={m.settings_section_authentication()} + description="User accounts and access control." + > + + updateField(["auth", "disabled"], v)} + label={m.settings_auth_disable()} + hint="When disabled, no login is required to access Backrest." + /> + + + + {users.map((user: any, index: number) => ( + + { + const newUsers = [...users]; + newUsers[index].name = e.target.value; + updateField(["auth", "users"], newUsers); + }} + disabled={user.isExisting} + flex={1} + /> + { + const newUsers = [...users]; + newUsers[index].passwordBcrypt = e.target.value; + newUsers[index].needsBcrypt = true; + updateField(["auth", "users"], newUsers); + }} + rootProps={{ flex: 1 }} + /> + { + const newUsers = [...users]; + newUsers.splice(index, 1); + updateField(["auth", "users"], newUsers); + }} + > + + + + ))} + - - + + + + - {/* Authentication Section */} - {/* @ts-ignore */} - - - {m.settings_section_authentication()} - - - - - - updateField(["auth", "disabled"], !!e.checked) + {/* Multihost Section */} + {isMultihostSyncEnabled && ( + + } + title={m.settings_section_multihost()} + description="Peer-to-peer synchronisation between Backrest instances." + > + + + {m.settings_multihost_intro()} + + + {m.settings_multihost_warning()} + + + + + + + navigator.clipboard.writeText( + getField(["multihost", "identity", "keyid"]) || "", + ) } + aria-label="Copy" > - {m.settings_auth_disable()} - - + + + + - - - {users.map((user: any, index: number) => ( - - { - const newUsers = [...users]; - newUsers[index].name = e.target.value; - updateField(["auth", "users"], newUsers); - }} - disabled={user.isExisting} - flex={1} - /> - { - const newUsers = [...users]; - newUsers[index].passwordBcrypt = e.target.value; - newUsers[index].needsBcrypt = true; - updateField(["auth", "users"], newUsers); - }} - rootProps={{ flex: 1 }} - /> - { - const newUsers = [...users]; - newUsers.splice(index, 1); - updateField(["auth", "users"], newUsers); - }} - > - - - - ))} + {/* Pairing Tokens */} + + + {(config.multihost?.pairingTokens || []).map( + (token, index) => ( + = initialTokenCount} + generatedTokenString={ + index >= initialTokenCount ? generatedToken : undefined + } + config={config} + onRemove={() => handleRemovePairingToken(index)} + /> + ), + )} + + {showGenerateForm && ( + + + + setTokenLabel(e.target.value)} + placeholder="e.g. laptop-2" + width="full" + /> + + + + setTokenTtl(e.value[0]) + } + > + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + {ttlOptions.items.map((o: any) => ( + + {o.label} + + ))} + + + + + + setTokenMaxUses(parseInt(e.target.value) || 0) + } + min={0} + width="full" + /> + + + + + + + + )} + + {!showGenerateForm && ( - - - - - - - {/* Multihost Section */} - {isMultihostSyncEnabled && ( - // @ts-ignore - - - {m.settings_section_multihost()} - - - - - {m.settings_multihost_intro()} - - - {m.settings_multihost_warning()} - - - - - - - navigator.clipboard.writeText( - getField(["multihost", "identity", "keyid"]) || "", - ) - } - aria-label="Copy" - > - - - - - - {/* Pairing Tokens */} - - - {(config.multihost?.pairingTokens || []).map( - (token, index) => { - const isExpired = - token.expiresAtUnix > 0n && - token.expiresAtUnix < - BigInt(Math.floor(Date.now() / 1000)); - const usesText = - token.maxUses === 0 - ? `${token.uses} uses (unlimited)` - : `${token.uses}/${token.maxUses} uses`; - const expiryText = - token.expiresAtUnix === 0n - ? "Never expires" - : isExpired - ? `Expired ${new Date(Number(token.expiresAtUnix) * 1000).toLocaleString()}` - : `Expires ${new Date(Number(token.expiresAtUnix) * 1000).toLocaleString()}`; - - return ( - - - - - {token.label || "(no label)"} - - - {expiryText} -- {usesText} - - - - handleRemovePairingToken(index) - } - aria-label="Remove token" - > - - - - - ); - }, - )} - - {generatedToken && ( - - - - - navigator.clipboard.writeText(generatedToken) - } - aria-label="Copy token" - > - - - - - )} - - {showGenerateForm && ( - - - - setTokenLabel(e.target.value)} - placeholder="e.g. laptop-2" - width="full" - /> - - - - setTokenTtl(e.value[0]) - } - > - {/* @ts-ignore */} - - {/* @ts-ignore */} - - - {/* @ts-ignore */} - - {ttlOptions.items.map((o: any) => ( - - {o.label} - - ))} - - - - - - setTokenMaxUses( - parseInt(e.target.value) || 0, - ) - } - min={0} - width="full" - /> - - - - - - - - )} - - {!showGenerateForm && ( - - )} - - - - - - updateField(["multihost", "authorizedClients"], items) - } - itemTypeName={m.settings_multihost_authorized_client_item()} - peerStates={peerStates} - config={config} - showInstanceUrl={false} - /> - - - {/* Pair with Server */} - - - {showPairWithServer && ( - - - - Paste a pairing token from another Backrest server - to add it as a known host. - - - setPairToken(e.target.value)} - placeholder=':#' - width="full" - /> - - - - setPairInstanceUrl(e.target.value) - } - placeholder="e.g. http://server:9898" - width="full" - /> - - - - - )} - - - - - updateField(["multihost", "knownHosts"], items) - } - itemTypeName={m.settings_multihost_known_host_item()} - peerStates={peerStates} - config={config} - showInstanceUrl={true} - /> - + )} - - - )} + - {/* Preview Section */} - {/* @ts-ignore */} - - - {m.settings_section_preview()} - - - - {JSON.stringify(formData, null, 2)} - - - - - - + + updateField(["multihost", "authorizedClients"], items) + } + peerStates={peerStates} + config={config} + showInstanceUrl={false} + /> + + + + + updateField(["multihost", "knownHosts"], items) + } + peerStates={peerStates} + config={config} + /> + + + + + )} + ); }; -// --- Peer Sub-components --- +// --- Pairing Token Item --- -const PeerFormList = ({ +const PairingTokenItem = ({ + token, + isNew, + generatedTokenString, + config, + onRemove, +}: { + token: any; + isNew: boolean; + generatedTokenString?: string; + config: any; + onRemove: () => void; +}) => { + const [showToken, setShowToken] = useState(isNew); + + // Build the full token string: :# + const fullTokenString = + generatedTokenString || + `${config.multihost?.identity?.keyid || ""}:${token.secret || ""}#${config.instance || ""}`; + + const isExpired = + token.expiresAtUnix > 0n && + token.expiresAtUnix < BigInt(Math.floor(Date.now() / 1000)); + const usesText = + token.maxUses === 0 + ? `${token.uses} uses (unlimited)` + : `${token.uses}/${token.maxUses} uses`; + const expiryText = + token.expiresAtUnix === 0n + ? "Never expires" + : isExpired + ? `Expired ${new Date(Number(token.expiresAtUnix) * 1000).toLocaleString()}` + : `Expires ${new Date(Number(token.expiresAtUnix) * 1000).toLocaleString()}`; + + return ( + + + + + {token.label || "(no label)"} + + + {expiryText} -- {usesText} + + + + setShowToken(!showToken)} + aria-label={showToken ? "Hide token" : "Show token"} + > + {showToken ? : } + + + + + + + {showToken && ( + + + navigator.clipboard.writeText(fullTokenString)} + aria-label="Copy token" + > + + + + )} + + ); +}; + +// --- Known Hosts List (with integrated pairing) --- + +const KnownHostsList = ({ items, onUpdate, - itemTypeName, peerStates, config, - showInstanceUrl, }: any) => { - const handleAdd = () => { - onUpdate([ - ...items, - { instanceId: "", keyId: "", instanceUrl: "", permissions: [] }, - ]); - }; + const [showAddForm, setShowAddForm] = useState(false); + const [pairToken, setPairToken] = useState(""); + const [pairInstanceUrl, setPairInstanceUrl] = useState(""); const handleRemove = (index: number) => { const next = [...items]; @@ -812,8 +676,173 @@ const PeerFormList = ({ onUpdate(next); }; + const handleAdd = () => { + try { + if (!pairToken.trim()) { + onUpdate([ + ...items, + { + instanceId: "", + keyId: "", + instanceUrl: pairInstanceUrl, + permissions: [ + { + type: Multihost_Permission_Type.PERMISSION_READ_OPERATIONS, + scopes: ["*"], + }, + ], + }, + ]); + setShowAddForm(false); + setPairToken(""); + setPairInstanceUrl(""); + return; + } + + const hashIdx = pairToken.indexOf("#"); + const colonIdx = pairToken.indexOf(":"); + if (hashIdx === -1 || colonIdx === -1 || colonIdx > hashIdx) { + throw new Error( + 'Invalid token format. Expected ":#"', + ); + } + const keyId = pairToken.substring(0, colonIdx); + const secret = pairToken.substring(colonIdx + 1, hashIdx); + const instanceId = pairToken.substring(hashIdx + 1); + + if (!keyId || !secret || !instanceId) { + throw new Error("Token is missing required fields"); + } + if (!pairInstanceUrl) { + throw new Error("Instance URL is required"); + } + + onUpdate([ + ...items, + { + instanceId, + keyId, + instanceUrl: pairInstanceUrl, + initialPairingSecret: secret, + permissions: [ + { + type: Multihost_Permission_Type.PERMISSION_READ_OPERATIONS, + scopes: ["*"], + }, + ], + }, + ]); + + setPairToken(""); + setPairInstanceUrl(""); + setShowAddForm(false); + alerts.success("Server added to known hosts. Save settings to apply."); + } catch (e: any) { + alerts.error(formatErrorAlert(e, "Failed to add known host")); + } + }; + return ( - + + {items.map((item: any, index: number) => ( + handleItemUpdate(index, val)} + onRemove={() => handleRemove(index)} + peerStates={peerStates} + showInstanceUrl={true} + config={config} + /> + ))} + + {showAddForm ? ( + + + + Paste a pairing token from another Backrest server, or leave blank + to configure manually. + + + setPairToken(e.target.value)} + placeholder=':#' + width="full" + /> + + + setPairInstanceUrl(e.target.value)} + placeholder="e.g. http://server:9898" + width="full" + /> + + + + + + + + ) : ( + + )} + + ); +}; + +// --- Peer Sub-components --- + +const PeerFormList = ({ + items, + onUpdate, + peerStates, + config, + showInstanceUrl, +}: any) => { + const handleRemove = (index: number) => { + const next = [...items]; + next.splice(index, 1); + onUpdate(next); + }; + + const handleItemUpdate = (index: number, val: any) => { + const next = [...items]; + next[index] = val; + onUpdate(next); + }; + + return ( + + {items.length === 0 && ( + + No trusted peers yet. Generate a pairing token above and share it with + another instance to get started. + + )} {items.map((item: any, index: number) => ( ))} - ); }; @@ -893,8 +916,6 @@ const PeerFormListItem = ({ )} - {/* Permissions (Only for known hosts logic in original? No, original had isKnownHost? logic) */} - {/* PeerPermissionsTile logic */} updateItem("permissions", perms)} @@ -927,6 +948,10 @@ const PeerPermissionsTile = ({ permissions, onUpdate, config }: any) => { label: m.settings_permission_read_ops(), value: Multihost_Permission_Type.PERMISSION_READ_OPERATIONS.toString(), }, + { + label: "Receive shared repos", + value: Multihost_Permission_Type.PERMISSION_RECEIVE_SHARED_REPOS.toString(), + }, ], }); @@ -993,32 +1018,34 @@ const PeerPermissionsTile = ({ permissions, onUpdate, config }: any) => { - - - handleUpdate(index, "scopes", e.value) - } - > - {/* @ts-ignore */} - + {perm.type !== Multihost_Permission_Type.PERMISSION_RECEIVE_SHARED_REPOS && ( + + + handleUpdate(index, "scopes", e.value) + } + > {/* @ts-ignore */} - - - {/* @ts-ignore */} - - {repoOptions.items.map((o: any) => ( - - {o.label} - - ))} - - - + + {/* @ts-ignore */} + + + {/* @ts-ignore */} + + {repoOptions.items.map((o: any) => ( + + {o.label} + + ))} + + + + )} { ); }; -// Mock Alert component if needed or use toast const Alert = ({ status, children }: any) => (