diff --git a/go/Dockerfile.memberreconciler b/go/Dockerfile.memberreconciler new file mode 100644 index 0000000..75afb40 --- /dev/null +++ b/go/Dockerfile.memberreconciler @@ -0,0 +1,20 @@ +# golang two stage build +FROM golang:1.25 AS first-stage + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download && go mod verify + +COPY . . + +WORKDIR /app/cmd/memberreconciler +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main +RUN ls + +FROM alpine:latest AS second-stage + +WORKDIR /app +COPY --from=first-stage /app/cmd/memberreconciler . +RUN echo "copied over binary to production stage" +CMD ["./main"] diff --git a/go/cmd/emailnotifierjob/main.go b/go/cmd/emailnotifierjob/main.go index 84871d9..e115ac2 100644 --- a/go/cmd/emailnotifierjob/main.go +++ b/go/cmd/emailnotifierjob/main.go @@ -68,7 +68,7 @@ func main() { // Initialize services humanSvc := human.NewService(db.Pool()) - networkSvc := network.NewService(db.Pool(), aeroSvc, billing.Noop()) + networkSvc := network.NewService(db.Pool(), aeroSvc, billing.Noop(), nil) slog.Info("starting email notification cycle") if err := runNotificationCycle(ctx, firestoreClient, aeroSvc, pusherSvc, humanSvc, networkSvc); err != nil { diff --git a/go/cmd/memberreconciler/main.go b/go/cmd/memberreconciler/main.go new file mode 100644 index 0000000..2ad5212 --- /dev/null +++ b/go/cmd/memberreconciler/main.go @@ -0,0 +1,143 @@ +// memberreconciler is a one-shot job (also safe to run on a cron) that makes +// the Firestore membership mirror (humans/{humanId}.networks) match the +// authoritative Postgres network_members table. +// +// Run on a cron to heal any drift from a dropped mirror +// write in network.Service. +// +// The reconciler reads the current Firestore state and only writes humans +// whose mirrored networks differ from Postgres. Writes cost ~3x reads, and in +// steady state drift is rare, so read-first keeps the cron nearly free. +package main + +import ( + "context" + "log/slog" + "os" + "slices" + "time" + + "cloud.google.com/go/firestore" + "github.com/flowy-live/llink/internal/billing" + "github.com/flowy-live/llink/internal/db" + "github.com/flowy-live/llink/internal/human" + "github.com/flowy-live/llink/internal/network" + "github.com/flowy-live/llink/internal/utils" + "google.golang.org/api/iterator" +) + +func main() { + ctx := context.Background() + + db.Init() + defer db.Cleanup() + + gcpProject := utils.MustGetEnv("GCP_PROJECT") + fs, err := firestore.NewClient(ctx, gcpProject) + if err != nil { + slog.Error("failed to create Firestore client", "error", err) + os.Exit(1) + } + defer fs.Close() + + humanSvc := human.NewService(db.Pool()) + // billing/aero/fs unused here — we only call ListAllMemberships. + networkSvc := network.NewService(db.Pool(), nil, billing.Noop(), nil) + + started := time.Now() + written, scanned, err := reconcile(ctx, fs, humanSvc, networkSvc) + if err != nil { + slog.Error("reconciliation failed", "error", err, "elapsed", time.Since(started)) + os.Exit(1) + } + slog.Info("reconciliation complete", + "humans_scanned", scanned, + "humans_written", written, + "elapsed", time.Since(started), + ) +} + +func reconcile( + ctx context.Context, + fs *firestore.Client, + humanSvc human.Service, + networkSvc network.Service, +) (written, scanned int, err error) { + humans, err := humanSvc.ListAll(ctx) + if err != nil { + return 0, 0, err + } + + memberships, err := networkSvc.ListAllMemberships(ctx) + if err != nil { + return 0, 0, err + } + + current, err := snapshotMirror(ctx, fs) + if err != nil { + return 0, 0, err + } + + bulk := fs.BulkWriter(ctx) + for _, h := range humans { + scanned++ + desired := memberships[h.ID] + if desired == nil { + desired = []string{} + } + if sameSet(current[h.ID], desired) { + continue + } + if _, err := bulk.Set(fs.Collection("humans").Doc(h.ID), map[string]any{ + "networks": desired, + "updated_at": firestore.ServerTimestamp, + }, firestore.MergeAll); err != nil { + return written, scanned, err + } + written++ + } + bulk.End() + return written, scanned, nil +} + +// snapshotMirror streams the humans collection once and returns a map of +// humanId -> current networks array. One iterator, N billed reads. +func snapshotMirror(ctx context.Context, fs *firestore.Client) (map[string][]string, error) { + out := map[string][]string{} + iter := fs.Collection("humans").Documents(ctx) + defer iter.Stop() + for { + doc, err := iter.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, err + } + var data struct { + Networks []string `firestore:"networks"` + } + if err := doc.DataTo(&data); err != nil { + slog.Warn("skipping malformed mirror doc", "id", doc.Ref.ID, "error", err) + continue + } + out[doc.Ref.ID] = data.Networks + } + return out, nil +} + +// sameSet reports whether a and b contain the same elements, ignoring order +// and duplicates. Firestore array ops don't preserve order, so set equality is +// the right comparison for the networks array. +func sameSet(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + ac := slices.Clone(a) + bc := slices.Clone(b) + slices.Sort(ac) + slices.Sort(bc) + ac = slices.Compact(ac) + bc = slices.Compact(bc) + return slices.Equal(ac, bc) +} diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index b45faf0..57e9d06 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -9,6 +9,7 @@ import ( "cloud.google.com/go/firestore" "cloud.google.com/go/storage" + firebase "firebase.google.com/go/v4" pbaero "github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal" "github.com/flowy-live/llink/internal/auth" @@ -62,7 +63,19 @@ func main() { defer aeroServer.Close() aeroSvc := pbaero.NewPrimaryClient(aeroServer) - authSvc := auth.NewAuthService(redisClient, aeroSvc) + gcpProject := utils.MustGetEnv("GCP_PROJECT") + fbApp, err := firebase.NewApp(ctx, &firebase.Config{ProjectID: gcpProject}) + if err != nil { + slog.Error("failed to init Firebase Admin app", "error", err) + os.Exit(1) + } + fbAuth, err := fbApp.Auth(ctx) + if err != nil { + slog.Error("failed to create Firebase auth client", "error", err) + os.Exit(1) + } + + authSvc := auth.NewAuthService(redisClient, aeroSvc, fbAuth) humanSvc := human.NewService(db.Pool()) billingSvc, err := billing.NewService(ctx, db.Pool(), billing.Config{ @@ -78,7 +91,14 @@ func main() { os.Exit(1) } - networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc) + firestoreClient, err := firestore.NewClient(ctx, gcpProject) + if err != nil { + slog.Error("failed to create Firestore client", "error", err) + os.Exit(1) + } + defer firestoreClient.Close() + + networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc, firestoreClient) particleSvc := particle.NewService(db.Pool(), networkSvc) depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{ GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"), @@ -87,14 +107,6 @@ func main() { waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc) livekitClient := livekit.NewClient() - gcpProject := utils.MustGetEnv("GCP_PROJECT") - firestoreClient, err := firestore.NewClient(ctx, gcpProject) - if err != nil { - slog.Error("failed to create Firestore client", "error", err) - os.Exit(1) - } - defer firestoreClient.Close() - h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, livekitClient, firestoreClient) withAuth := func(hf http.HandlerFunc) http.Handler { @@ -125,6 +137,7 @@ func main() { // Auth mux.Handle("POST /auth/sign-out", withAuth(h.SignOut)) mux.Handle("GET /auth/me", withAuth(h.GetCurrentHuman)) + mux.Handle("POST /auth/firebase-token", withAuth(h.FirebaseToken)) // Settings mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings)) @@ -134,7 +147,7 @@ func main() { mux.Handle("GET /networks", withAuth(h.ListNetworks)) mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork)) mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork)) - // mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork)) + mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork)) // Billing (network admin only; admin check happens inside each handler) mux.Handle("GET /networks/{id}/billing", withAuth(h.GetNetworkBilling)) diff --git a/go/cmd/pusherservice/main.go b/go/cmd/pusherservice/main.go index 0dab5cb..9bc5cb6 100644 --- a/go/cmd/pusherservice/main.go +++ b/go/cmd/pusherservice/main.go @@ -37,8 +37,8 @@ func main() { pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher) // Services - authSvc := auth.NewAuthService(authRedis, nil) // nil aeroSvc — pusher only calls GetSession - networkSvc := network.NewService(db.Pool(), nil, billing.Noop()) // nil aeroSvc / noop billing — pusher never mutates membership + authSvc := auth.NewAuthService(authRedis, nil, nil) // nil aeroSvc / fbAuth — pusher only calls GetSession + networkSvc := network.NewService(db.Pool(), nil, billing.Noop(), nil) // nil aeroSvc / noop billing / nil firestore — pusher never mutates membership // Pod identity (use hostname in k8s, which is the pod name) podID, err := os.Hostname() diff --git a/go/go.mod b/go/go.mod index e9326e0..d9b8116 100644 --- a/go/go.mod +++ b/go/go.mod @@ -37,10 +37,12 @@ require ( cloud.google.com/go/longrunning v0.7.0 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect dario.cat/mergo v1.0.2 // indirect + firebase.google.com/go/v4 v4.19.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect + github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/benbjohnson/clock v1.3.5 // indirect @@ -77,6 +79,8 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/gofrs/uuid/v5 v5.2.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/cel-go v0.27.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect @@ -173,6 +177,7 @@ require ( golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.256.0 // indirect + google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect diff --git a/go/go.sum b/go/go.sum index db9694f..39a66c2 100644 --- a/go/go.sum +++ b/go/go.sum @@ -30,6 +30,8 @@ cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4 cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8= +firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= @@ -42,6 +44,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= +github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= @@ -142,12 +146,17 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/uuid/v5 v5.2.0 h1:qw1GMx6/y8vhVsx626ImfKMuS5CvJmhIKKtuyvfajMM= github.com/gofrs/uuid/v5 v5.2.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -456,6 +465,7 @@ golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= @@ -473,6 +483,9 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI= google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= +google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 h1:LvZVVaPE0JSqL+ZWb6ErZfnEOKIqqFWUJE2D0fObSmc= google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= @@ -481,6 +494,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/go/internal/auth/service.go b/go/internal/auth/service.go index 0d6a85b..f15b1f8 100644 --- a/go/internal/auth/service.go +++ b/go/internal/auth/service.go @@ -9,6 +9,7 @@ import ( "strings" "time" + firebaseauth "firebase.google.com/go/v4/auth" "github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal/utils" "github.com/redis/go-redis/v9" @@ -56,16 +57,27 @@ type AuthService interface { ExtendSession(ctx context.Context, sessionToken string) error SignOut(ctx context.Context, sessionToken string) error + // MintFirebaseCustomToken returns a Firebase custom token with uid=humanId and no custom claims. + MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error) + IsSystemAdmin(ctx context.Context, email string) bool } type authServiceImpl struct { redisClient *redis.Client aeroSvc pbaero.PrimaryClient + fbAuth *firebaseauth.Client } -func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient) AuthService { - return &authServiceImpl{redisClient: redisClient, aeroSvc: aeroSvc} +func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient, fbAuth *firebaseauth.Client) AuthService { + return &authServiceImpl{redisClient: redisClient, aeroSvc: aeroSvc, fbAuth: fbAuth} +} + +func (a *authServiceImpl) MintFirebaseCustomToken(ctx context.Context, humanId string) (string, error) { + if humanId == "" { + return "", errors.New("humanId is required") + } + return a.fbAuth.CustomToken(ctx, humanId) } func (a *authServiceImpl) IsSystemAdmin(ctx context.Context, email string) bool { diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index 85feeec..86d9488 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -94,6 +94,10 @@ type SignInResponse struct { Token string `json:"token"` } +type FirebaseTokenResponse struct { + Token string `json:"token"` +} + // Network Request DTOs type CreateNetworkRequest struct { @@ -240,6 +244,27 @@ func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } +// FirebaseToken mints a Firebase custom token for the authenticated human so +// the client can signInWithCustomToken and have request.auth.uid populated in +// Firestore security rules. +func (h *Handler) FirebaseToken(w http.ResponseWriter, r *http.Request) { + humanId, ok := middleware.HumanIdFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + token, err := h.authSvc.MintFirebaseCustomToken(r.Context(), humanId) + if err != nil { + slog.Error("failed to mint Firebase custom token", "error", err, "humanId", humanId) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(FirebaseTokenResponse{Token: token}) +} + // SignOut deletes the session from the token in headers func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) { token := extractBearerToken(r) @@ -521,34 +546,28 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// RemoveMemberFromNetwork removes a member from a network +// RemoveMemberFromNetwork removes a member from a network. Admin-only. +// Admins cannot remove themselves — doing so would leave networks.admin_human_id +// dangling. Removal of a non-member is a no-op (204). func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) { - humanId, ok := middleware.HumanIdFromContext(r.Context()) + net, _, ok := h.loadNetworkForAdmin(w, r) if !ok { - http.Error(w, "unauthorized", http.StatusUnauthorized) return } - networkID := r.PathValue("id") memberHumanId := r.PathValue("humanId") - if networkID == "" || memberHumanId == "" { - http.Error(w, "network id and member humanId are required", http.StatusBadRequest) + if memberHumanId == "" { + http.Error(w, "member humanId is required", http.StatusBadRequest) return } - isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId) - if err != nil { - slog.Error("failed to check network membership", "error", err, "network_id", networkID, "humanId", humanId) - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } - if !isMember { - http.Error(w, "access denied", http.StatusForbidden) + if memberHumanId == net.AdminHumanId { + http.Error(w, "admin cannot remove themselves", http.StatusConflict) return } - if err := h.networkSvc.RemoveMember(r.Context(), networkID, memberHumanId); err != nil { - slog.Error("failed to remove member from network", "error", err, "network_id", networkID, "memberHumanId", memberHumanId) + if err := h.networkSvc.RemoveMember(r.Context(), net.ID, memberHumanId); err != nil { + slog.Error("failed to remove member from network", "error", err, "network_id", net.ID, "memberHumanId", memberHumanId) http.Error(w, "internal server error", http.StatusInternalServerError) return } diff --git a/go/internal/network/repository.go b/go/internal/network/repository.go index 523a89b..d755ce5 100644 --- a/go/internal/network/repository.go +++ b/go/internal/network/repository.go @@ -47,6 +47,7 @@ type repository interface { getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) isMember(ctx context.Context, networkID, humanId string) (bool, error) listAll(ctx context.Context) ([]*Network, error) + listAllMemberships(ctx context.Context) (map[string][]string, error) // Invitations createInvitation(ctx context.Context, networkID, email string) error @@ -232,6 +233,27 @@ func (r *repositoryImpl) isMember(ctx context.Context, networkID, humanId string return isMember, err } +// listAllMemberships returns humanId -> networkIds for every human with at +// least one membership. Humans with zero memberships are absent from the map; +// callers layer them in separately. +func (r *repositoryImpl) listAllMemberships(ctx context.Context) (map[string][]string, error) { + rows, err := r.pool.Query(ctx, `SELECT human_id, network_id FROM network_members`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := map[string][]string{} + for rows.Next() { + var humanId, networkId string + if err := rows.Scan(&humanId, &networkId); err != nil { + return nil, err + } + out[humanId] = append(out[humanId], networkId) + } + return out, rows.Err() +} + func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) { rows, err := r.pool.Query(ctx, `SELECT `+networkColumns+` FROM networks`, diff --git a/go/internal/network/service.go b/go/internal/network/service.go index c06392a..1db969a 100644 --- a/go/internal/network/service.go +++ b/go/internal/network/service.go @@ -8,6 +8,7 @@ import ( "log/slog" "strings" + "cloud.google.com/go/firestore" pbaero "github.com/flowy-live/llink/genproto/aero" "github.com/flowy-live/llink/internal/billing" "github.com/flowy-live/llink/internal/utils" @@ -37,6 +38,10 @@ type Service interface { IsMember(ctx context.Context, networkID, humanId string) (bool, error) // ListAll returns all networks with their members ListAll(ctx context.Context) ([]*Network, error) + // ListAllMemberships returns humanId -> networkIds for every human with at + // least one membership. Humans with zero memberships are absent from the map. + // Used by the membership reconciler to diff the Firestore mirror. + ListAllMemberships(ctx context.Context) (map[string][]string, error) // Invitations (email-based, for users who haven't registered yet) InviteByEmail(ctx context.Context, networkID string, emails []string) error @@ -51,14 +56,19 @@ type serviceImpl struct { repo repository aeroSvc pbaero.PrimaryClient billingSvc billing.Service + // fs mirrors network membership into humans/{humanId}.networks so + // Firestore security rules can check membership at rule-eval time. + // May be nil in services that never mutate membership (pusher). + fs *firestore.Client } -func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient, billingSvc billing.Service) Service { +func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient, billingSvc billing.Service, fs *firestore.Client) Service { return &serviceImpl{ pool: pool, repo: newRepository(pool), aeroSvc: aeroSvc, billingSvc: billingSvc, + fs: fs, } } @@ -105,23 +115,66 @@ func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds if slices.Contains(humanIds, "") { return fmt.Errorf("invalid humanId") } - return s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error { + if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error { for _, humanId := range humanIds { if err := s.repo.addMember(ctx, tx, networkID, humanId); err != nil { return err } } return nil - }) + }); err != nil { + return err + } + + for _, humanId := range humanIds { + s.mirrorAddMembership(ctx, humanId, networkID) + } + return nil } func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error { if humanId == "" { return fmt.Errorf("invalid humanId") } - return s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error { + if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error { return s.repo.removeMember(ctx, tx, networkID, humanId) - }) + }); err != nil { + return err + } + + s.mirrorRemoveMembership(ctx, humanId, networkID) + return nil +} + +// mirrorAddMembership / mirrorRemoveMembership keep the Firestore membership +// mirror (humans/{humanId}.networks) in sync with Postgres. Called after the +// Postgres transaction commits. Failures are logged but not returned: Postgres +// is the source of truth and the reconciler will heal drift. A missing fs +// client (pusher) no-ops. +func (s *serviceImpl) mirrorAddMembership(ctx context.Context, humanId, networkID string) { + if s.fs == nil { + return + } + _, err := s.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{ + "networks": firestore.ArrayUnion(networkID), + "updated_at": firestore.ServerTimestamp, + }, firestore.MergeAll) + if err != nil { + slog.Error("firestore mirror add failed", "error", err, "humanId", humanId, "networkID", networkID) + } +} + +func (s *serviceImpl) mirrorRemoveMembership(ctx context.Context, humanId, networkID string) { + if s.fs == nil { + return + } + _, err := s.fs.Collection("humans").Doc(humanId).Set(ctx, map[string]any{ + "networks": firestore.ArrayRemove(networkID), + "updated_at": firestore.ServerTimestamp, + }, firestore.MergeAll) + if err != nil { + slog.Error("firestore mirror remove failed", "error", err, "humanId", humanId, "networkID", networkID) + } } // mutateMembers runs fn in a tx, recounts seats, calls billing.SyncSeats, @@ -174,6 +227,10 @@ func (s *serviceImpl) ListAll(ctx context.Context) ([]*Network, error) { return s.repo.listAll(ctx) } +func (s *serviceImpl) ListAllMemberships(ctx context.Context) (map[string][]string, error) { + return s.repo.listAllMemberships(ctx) +} + func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error { network, err := s.repo.getByID(ctx, networkID) if err != nil { @@ -226,13 +283,18 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu return fmt.Errorf("invalid humanId") } - return s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error { + if err := s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error { err := s.repo.deleteInvitation(ctx, tx, networkID, normalized) if err != nil { return err } return s.repo.addMember(ctx, tx, networkID, humanId) - }) + }); err != nil { + return err + } + + s.mirrorAddMembership(ctx, humanId, networkID) + return nil } func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error { diff --git a/go/internal/network/service_test.go b/go/internal/network/service_test.go index c14e4fa..5901267 100644 --- a/go/internal/network/service_test.go +++ b/go/internal/network/service_test.go @@ -32,7 +32,7 @@ func newTestService(t *testing.T) network.Service { ShootEmail(gomock.Any(), gomock.Any()). Return(&pbaero.ShootEmailResponse{}, nil). AnyTimes() - return network.NewService(dbPool, mockAero, billing.Noop()) + return network.NewService(dbPool, mockAero, billing.Noop(), nil) } func TestNetworkService(t *testing.T) { diff --git a/go/internal/particle/service_test.go b/go/internal/particle/service_test.go index 61a6946..f128705 100644 --- a/go/internal/particle/service_test.go +++ b/go/internal/particle/service_test.go @@ -34,7 +34,7 @@ func getStreamStatus(data json.RawMessage) string { func TestParticleService_CreateAndGet(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool, nil, billing.Noop()) + networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil) svc := particle.NewService(dbPool, networkSvc) // Create a network first @@ -73,7 +73,7 @@ func TestParticleService_CreateAndGet(t *testing.T) { func TestParticleService_NestedParticles(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool, nil, billing.Noop()) + networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil) svc := particle.NewService(dbPool, networkSvc) // Create a network @@ -119,7 +119,7 @@ func TestParticleService_NestedParticles(t *testing.T) { func TestParticleService_CustomVisibility(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool, nil, billing.Noop()) + networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil) svc := particle.NewService(dbPool, networkSvc) // Create a network with a member @@ -161,7 +161,7 @@ func TestParticleService_CustomVisibility(t *testing.T) { func TestParticleService_UpdateAndDelete(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool, nil, billing.Noop()) + networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil) svc := particle.NewService(dbPool, networkSvc) // Create a network @@ -199,7 +199,7 @@ func TestParticleService_UpdateAndDelete(t *testing.T) { func TestParticleService_ListRootParticles(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool, nil, billing.Noop()) + networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil) svc := particle.NewService(dbPool, networkSvc) // Create a network @@ -225,7 +225,7 @@ func TestParticleService_ListRootParticles(t *testing.T) { func TestParticleService_OpenCloseStream(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool, nil, billing.Noop()) + networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil) svc := particle.NewService(dbPool, networkSvc) // Create a network @@ -273,7 +273,7 @@ func TestParticleService_OpenCloseStream(t *testing.T) { func TestParticleService_NotAStream(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool, nil, billing.Noop()) + networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil) svc := particle.NewService(dbPool, networkSvc) // Create a network @@ -302,7 +302,7 @@ func TestParticleService_NotAStream(t *testing.T) { func TestParticleService_AccessInheritance(t *testing.T) { ctx := context.Background() - networkSvc := network.NewService(dbPool, nil, billing.Noop()) + networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil) svc := particle.NewService(dbPool, networkSvc) // Create a network with members diff --git a/go/k8s/dev/memberreconciler.yaml b/go/k8s/dev/memberreconciler.yaml new file mode 100644 index 0000000..b876596 --- /dev/null +++ b/go/k8s/dev/memberreconciler.yaml @@ -0,0 +1,38 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: memberreconciler +spec: + schedule: "0 2 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + metadata: + labels: + app: memberreconciler + spec: + serviceAccountName: default-service-account + nodeSelector: + cloud.google.com/gke-spot: "true" + restartPolicy: Never + containers: + - name: memberreconciler + image: "memberreconciler" + resources: + requests: + memory: "64Mi" + cpu: 50m + limits: + memory: "64Mi" + cpu: 50m + env: + - name: "GCP_PROJECT" + value: "flowy-dev-440017" + - name: "LLINK_POSTGRES_CONNECTION_URL" + valueFrom: + secretKeyRef: + name: shared-secrets + key: LLINK_POSTGRES_CONNECTION_URL diff --git a/go/k8s/prod/memberreconciler.yaml b/go/k8s/prod/memberreconciler.yaml new file mode 100644 index 0000000..88c7d8e --- /dev/null +++ b/go/k8s/prod/memberreconciler.yaml @@ -0,0 +1,36 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: memberreconciler +spec: + schedule: "0 2 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + metadata: + labels: + app: memberreconciler + spec: + serviceAccountName: default-service-account + restartPolicy: Never + containers: + - name: memberreconciler + image: "memberreconciler" + resources: + requests: + memory: "64Mi" + cpu: 50m + limits: + memory: "64Mi" + cpu: 50m + env: + - name: "GCP_PROJECT" + value: "flowy-prod-440017" + - name: "LLINK_POSTGRES_CONNECTION_URL" + valueFrom: + secretKeyRef: + name: shared-secrets + key: LLINK_POSTGRES_CONNECTION_URL diff --git a/go/skaffold.yaml b/go/skaffold.yaml index 34bbe9c..1669737 100644 --- a/go/skaffold.yaml +++ b/go/skaffold.yaml @@ -128,6 +128,41 @@ profiles: --- apiVersion: skaffold/v4beta11 kind: Config +metadata: + name: memberreconciler +build: + local: {} + tagPolicy: + gitCommit: + variant: AbbrevCommitSha +profiles: + - name: dev + build: + artifacts: + - image: memberreconciler + context: . + docker: + dockerfile: Dockerfile.memberreconciler + manifests: + rawYaml: + - k8s/dev/memberreconciler.yaml + deploy: + kubectl: {} + - name: prod + build: + artifacts: + - image: memberreconciler + context: . + docker: + dockerfile: Dockerfile.memberreconciler + manifests: + rawYaml: + - k8s/prod/memberreconciler.yaml + deploy: + kubectl: {} +--- +apiVersion: skaffold/v4beta11 +kind: Config metadata: name: pusher build: diff --git a/js/src/api/client.ts b/js/src/api/client.ts index d1671e0..938355d 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -5,6 +5,7 @@ import { BillingStatusSchema, CheckoutSessionResponseSchema, DepotObjectSchema, + FirebaseTokenResponseSchema, GetLivekitTokenResponseSchema, HumanSchema, ListInvitationsResponseSchema, @@ -121,6 +122,14 @@ class ApiClient { await this.requestVoid("POST", "/auth/sign-out"); } + async getFirebaseToken() { + return this.request( + FirebaseTokenResponseSchema, + "POST", + "/auth/firebase-token", + ); + } + // TODO: security: require passing in the particle id once api deprecates this async getParticleDownloadUrl(objectId: string): Promise { const response = await this.fetch( @@ -183,10 +192,10 @@ class ApiClient { ); } - async removeMember(networkId: string, email: string): Promise { + async removeMember(networkId: string, humanId: string): Promise { await this.requestVoid( "DELETE", - `/networks/${networkId}/members/${email}`, + `/networks/${networkId}/members/${humanId}`, ); } diff --git a/js/src/api/types.ts b/js/src/api/types.ts index d1b1a60..42e014a 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -263,6 +263,11 @@ export const SignInResponseSchema = z.object({ }); export type SignInResponse = z.infer; +export const FirebaseTokenResponseSchema = z.object({ + token: z.string(), +}); +export type FirebaseTokenResponse = z.infer; + // --- Billing types --- export const BillingCadenceSchema = z.enum(["monthly", "annual"]); diff --git a/js/src/components/composing-indicator.tsx b/js/src/components/composing-indicator.tsx index 8c22b51..d53e3c4 100644 --- a/js/src/components/composing-indicator.tsx +++ b/js/src/components/composing-indicator.tsx @@ -1,4 +1,5 @@ import type { Human } from "@/api/types"; +import { resolveHumanDisplay } from "@/lib/humans"; import type { ComposingUser } from "@/features/particles/stream-presence-context"; interface ComposingIndicatorProps { @@ -22,8 +23,7 @@ export function ComposingIndicator({ style={{ writingMode: "vertical-rl" }} > {users.map((u) => { - const human = networkHumans?.find((h) => h.id === u.humanId); - const name = human?.email_prefix ?? u.humanId; + const { displayName } = resolveHumanDisplay(u.humanId, networkHumans); const modeLabel = u.mode === "typing" ? "typing" : "recording"; return ( @@ -37,7 +37,7 @@ export function ComposingIndicator({ - {name} {modeLabel} + {displayName} {modeLabel} ); diff --git a/js/src/components/confirm-destructive-overlay.tsx b/js/src/components/confirm-destructive-overlay.tsx new file mode 100644 index 0000000..ffd51f5 --- /dev/null +++ b/js/src/components/confirm-destructive-overlay.tsx @@ -0,0 +1,72 @@ +import { useEffect } from "react"; +import { createPortal } from "react-dom"; +import { Button } from "@/components/ui/button"; + +interface ConfirmDestructiveOverlayProps { + title: string; + description: React.ReactNode; + confirmLabel: string; + pendingLabel?: string; + isPending: boolean; + onConfirm: () => void; + onClose: () => void; +} + +export function ConfirmDestructiveOverlay({ + title, + description, + confirmLabel, + pendingLabel = "Working…", + isPending, + onConfirm, + onClose, +}: ConfirmDestructiveOverlayProps) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener("keydown", handler, { capture: true }); + return () => window.removeEventListener("keydown", handler, { capture: true }); + }, [onClose]); + + return createPortal( +
+
+
+
+

{title}

+ + + Esc + {" "} + to close + +
+ +
{description}
+ +
+ + +
+
+
, + document.body, + ); +} diff --git a/js/src/features/network-selector.tsx b/js/src/features/network-selector.tsx index ae6e841..01e1b86 100644 --- a/js/src/features/network-selector.tsx +++ b/js/src/features/network-selector.tsx @@ -19,7 +19,7 @@ import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { useNetworks } from "@/hooks/use-networks"; -import { useMyInvitations, useAcceptInvitation } from "@/hooks/use-invitations"; +import { useMyInvitations, useAcceptInvitation } from "@/hooks/use-member-management"; import { apiClient } from "@/api/client"; import { Progress } from "@/components/ui/progress"; import type { Network, Invitation } from "@/api/types"; diff --git a/js/src/features/network-settings.tsx b/js/src/features/network-settings.tsx index f44ddbf..81edf50 100644 --- a/js/src/features/network-settings.tsx +++ b/js/src/features/network-settings.tsx @@ -15,12 +15,22 @@ import { useNetworkInvitations, useInviteMembers, useRevokeInvitation, -} from "@/hooks/use-invitations"; + useRemoveMember, +} from "@/hooks/use-member-management"; import { useAuthStore } from "@/stores/auth-store"; import { BillingSection } from "@/features/network-billing"; +import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay"; import type { Human } from "@/api/types"; -function MemberRow({ human, isAdmin }: { human: Human; isAdmin: boolean }) { +function MemberRow({ + human, + isAdmin, + onRemove, +}: { + human: Human; + isAdmin: boolean; + onRemove?: () => void; +}) { const initials = human.email_prefix.slice(0, 2).toUpperCase(); return ( @@ -40,6 +50,17 @@ function MemberRow({ human, isAdmin }: { human: Human; isAdmin: boolean }) { Admin )} + {onRemove && ( + + )}
); } @@ -170,6 +191,8 @@ export default function NetworkSettingsPage() { const { data: invitations } = useNetworkInvitations(networkId!); const currentUser = useAuthStore((s) => s.user); const isAdmin = currentUser?.id === network?.admin_human.id; + const [memberToRemove, setMemberToRemove] = useState(null); + const removeMember = useRemoveMember(networkId!); const billingRef = useRef(null); @@ -228,17 +251,23 @@ export default function NetworkSettingsPage() { } /> - {network?.humans.map((human, index) => ( -
- - {index < network.humans.length - 1 && ( - - )} -
- ))} + {network?.humans.map((human, index) => { + const isRowAdmin = human.id === network.admin_human.id; + const canRemove = + isAdmin && !isRowAdmin && human.id !== currentUser?.id; + return ( +
+ setMemberToRemove(human) : undefined} + /> + {index < network.humans.length - 1 && ( + + )} +
+ ); + })} {isAdmin && network && ( @@ -299,6 +328,42 @@ export default function NetworkSettingsPage() {
+ + {memberToRemove && ( + +
  • + They'll lose access to this network's streams and files within + seconds. +
  • +
  • Any content they posted stays in the network.
  • +
  • + If they're in a live huddle, they may remain until the call ends. +
  • + + } + confirmLabel="Remove" + pendingLabel="Removing…" + isPending={removeMember.isPending} + onConfirm={() => { + const target = memberToRemove; + removeMember.mutate(target.id, { + onSuccess: () => { + toast.success(`Removed ${target.email}`); + setMemberToRemove(null); + }, + onError: (err) => { + toast.error(err.message || "Failed to remove member"); + }, + }); + }} + onClose={() => { + if (!removeMember.isPending) setMemberToRemove(null); + }} + /> + )}
    ); } diff --git a/js/src/features/particles/delete-particle-overlay.tsx b/js/src/features/particles/delete-particle-overlay.tsx index 3287c5c..7f15b86 100644 --- a/js/src/features/particles/delete-particle-overlay.tsx +++ b/js/src/features/particles/delete-particle-overlay.tsx @@ -1,7 +1,6 @@ -import { useCallback, useEffect, useState } from "react"; -import { createPortal } from "react-dom"; +import { useCallback, useState } from "react"; import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; +import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay"; import { softDeleteParticle } from "@/lib/firestore-particles"; import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; import type { Particle } from "@/api/types"; @@ -40,55 +39,20 @@ export function DeleteParticleOverlay({ } }, [deleting, networkId, onClose, particle.id, streamId, userId]); - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - onClose(); - } - }; - window.addEventListener("keydown", handler, { capture: true }); - return () => window.removeEventListener("keydown", handler, { capture: true }); - }, [onClose]); - - return createPortal( -
    -
    -
    -
    -

    Delete this particle?

    - - - Esc - {" "} - to close - -
    - -

    + return ( + This cannot be undone. Other viewers will see a "This particle was deleted" message in its place.

    - -
    - - -
    -
    -
    , - document.body, + } + confirmLabel="Delete" + pendingLabel="Deleting…" + isPending={deleting} + onConfirm={handleDelete} + onClose={onClose} + /> ); } diff --git a/js/src/features/particles/deleted-particle-view.tsx b/js/src/features/particles/deleted-particle-view.tsx index 885bd1a..8244aea 100644 --- a/js/src/features/particles/deleted-particle-view.tsx +++ b/js/src/features/particles/deleted-particle-view.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { Trash2 } from "lucide-react"; import type { Particle } from "@/api/types"; import { useNetwork } from "@/hooks/use-networks"; +import { resolveHumanDisplay } from "@/lib/humans"; // How long to linger on a tombstone before auto-advancing. Matches the // "reading" cadence of a short text particle. @@ -24,8 +25,8 @@ export function DeletedParticleView({ const deleterId = "deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined; const deleter = deleterId - ? network?.humans?.find((h) => h.id === deleterId) - : undefined; + ? resolveHumanDisplay(deleterId, network?.humans) + : null; useEffect(() => { if (paused) return; @@ -42,7 +43,7 @@ export function DeletedParticleView({ This particle was deleted

    {deleter && ( -

    by {deleter.email_prefix}

    +

    by {deleter.displayName}

    )}
    diff --git a/js/src/features/particles/fallback-particle-view.tsx b/js/src/features/particles/fallback-particle-view.tsx index 46bcfcc..2561b4f 100644 --- a/js/src/features/particles/fallback-particle-view.tsx +++ b/js/src/features/particles/fallback-particle-view.tsx @@ -7,6 +7,8 @@ import { CardTitle, } from "@/components/ui/card"; import { FileIcon, HelpCircleIcon, ScrollTextIcon, BookOpenIcon } from "lucide-react"; +import { useNetwork } from "@/hooks/use-networks"; +import { resolveHumanDisplay } from "@/lib/humans"; const TYPE_META: Record = { quest: { icon: ScrollTextIcon, label: "Quest" }, @@ -16,9 +18,12 @@ const TYPE_META: Record = { interface FallbackParticleViewProps { particle: Particle; + networkId: string; } -export function FallbackParticleView({ particle }: FallbackParticleViewProps) { +export function FallbackParticleView({ particle, networkId }: FallbackParticleViewProps) { + const network = useNetwork(networkId); + const creator = resolveHumanDisplay(particle.created_by_human_id, network?.humans); const meta = TYPE_META[particle.type] ?? { icon: HelpCircleIcon, label: particle.type, @@ -51,7 +56,7 @@ export function FallbackParticleView({ particle }: FallbackParticleViewProps) {

    - From {particle.created_by_human_id} + From {creator.displayName}

    diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index ae9d1a4..9b06cce 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -18,6 +18,7 @@ import { useLiveLatestChild } from "@/hooks/use-particle"; import { useAuthStore } from "@/stores/auth-store"; import { particlePath } from "@/lib/particle-path"; import { getInitials } from "@/lib/utils"; +import { resolveHumanDisplay } from "@/lib/humans"; import { RelativeTimestamp } from "@/components/relative-timestamp"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Separator } from "@/components/ui/separator"; @@ -174,9 +175,11 @@ const StreamRow = memo(function StreamRow({ } // Group stream if (isCurrentUser) return "You: "; - const creator = network?.humans?.find((h) => h.id === latestChild.created_by_human_id); - const name = creator?.email_prefix ?? latestChild.created_by_human_id; - const capitalized = name.charAt(0).toUpperCase() + name.slice(1); + const { displayName } = resolveHumanDisplay( + latestChild.created_by_human_id, + network?.humans, + ); + const capitalized = displayName.charAt(0).toUpperCase() + displayName.slice(1); return `${capitalized}: `; }, [latestChild, userId, isDM, network]); diff --git a/js/src/features/particles/particle-view-resolver.tsx b/js/src/features/particles/particle-view-resolver.tsx index 7c984a1..36a1c7b 100644 --- a/js/src/features/particles/particle-view-resolver.tsx +++ b/js/src/features/particles/particle-view-resolver.tsx @@ -1,6 +1,10 @@ -import { useParams } from "react-router-dom"; +import { useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { Lock } from "lucide-react"; import { useLiveParticle } from "@/hooks/use-particle"; import { particlePath } from "@/lib/particle-path"; +import { Button } from "@/components/ui/button"; import { StreamView } from "@/features/particles/stream-view"; import { FolderView } from "@/features/particles/folder-view"; @@ -26,22 +30,11 @@ export default function ParticleViewResolver() { ); } - if (error) { - return ( -
    -

    Failed to load particle

    -
    - ); - } - - if (!particle) { - return ( -
    -

    - Particle: {segments.join(" / ")} -

    -
    - ); + if (error || !particle) { + // Errors here are almost always Firestore permission-denied — the user lost + // access to the network or to a custom-visibility particle. The React Router + // stays on the dead route, so without an explicit escape the user is stuck. + return ; } switch (particle.type) { @@ -59,3 +52,28 @@ export default function ParticleViewResolver() { ); } } + +function InaccessibleParticle() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + useEffect(() => { + // Refresh the networks list so the home page reflects current access. + queryClient.invalidateQueries({ queryKey: ["networks"] }); + }, [queryClient]); + + return ( +
    + +
    +

    This particle isn't available

    +

    + It may have been deleted, or your access was removed. +

    +
    + +
    + ); +} diff --git a/js/src/features/particles/reaction-bar.tsx b/js/src/features/particles/reaction-bar.tsx index 2c9bdf3..d04c077 100644 --- a/js/src/features/particles/reaction-bar.tsx +++ b/js/src/features/particles/reaction-bar.tsx @@ -3,6 +3,7 @@ import { Plus, X } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { REACTION_EMOJIS, type Reactions } from "@/api/types"; import { cn } from "@/lib/utils"; +import { resolveHumanDisplay } from "@/lib/humans"; import type { Human } from "@/api/types"; interface ReactionBarProps { @@ -17,10 +18,7 @@ function getReactorNames( humans?: Human[], ): string { return humanIds - .map((id) => { - const human = humans?.find((h) => h.id === id); - return human?.email_prefix ?? id; - }) + .map((id) => resolveHumanDisplay(id, humans).displayName) .join(", "); } diff --git a/js/src/features/particles/stream-members-overlay.tsx b/js/src/features/particles/stream-members-overlay.tsx index f9ba7e2..c95a089 100644 --- a/js/src/features/particles/stream-members-overlay.tsx +++ b/js/src/features/particles/stream-members-overlay.tsx @@ -12,6 +12,7 @@ import { updateParticleVisibleTo } from "@/lib/firestore-particles"; import { particlePath, toFirestoreDocPath } from "@/lib/particle-path"; import { useNetwork } from "@/hooks/use-networks"; import { cn, getInitials } from "@/lib/utils"; +import { resolveHumanDisplay } from "@/lib/humans"; import type { Particle } from "@/api/types"; import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store"; @@ -158,7 +159,7 @@ export function StreamMembersOverlay({
      {memberIds.map((id) => { - const human = humans.find((h) => h.id === id); + const display = resolveHumanDisplay(id, humans); const isCreatorRow = id === creatorId; const canRemove = isCreator && visibility.mode === "custom" && !isCreatorRow; @@ -169,11 +170,16 @@ export function StreamMembersOverlay({ > - {human ? getInitials(human.email) : "?"} + {display.initials} - - {human?.email_prefix ?? id} + + {display.displayName} {isCreatorRow && ( @@ -185,7 +191,7 @@ export function StreamMembersOverlay({ type="button" onClick={() => removeMember(id)} className="rounded p-1 text-white/30 opacity-0 transition-opacity hover:bg-white/10 hover:text-white/70 group-hover:opacity-100" - aria-label={`Remove ${human?.email_prefix ?? id}`} + aria-label={`Remove ${display.displayName}`} > diff --git a/js/src/features/particles/stream-top-bar.tsx b/js/src/features/particles/stream-top-bar.tsx index 3dc0fb4..f028032 100644 --- a/js/src/features/particles/stream-top-bar.tsx +++ b/js/src/features/particles/stream-top-bar.tsx @@ -24,7 +24,7 @@ import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbS import { WindowControls } from "@/components/window-controls"; import { RelativeTimestamp } from "@/components/relative-timestamp"; import { useStreamPresence } from "@/features/particles/stream-presence-context"; -import { getInitials } from "@/lib/utils"; +import { resolveHumanDisplay } from "@/lib/humans"; function getParticleDisplayName(particle: Particle): string { switch (particle.type) { @@ -112,18 +112,17 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) { {huddleParticipants.map((humanId) => { - const human = network?.humans?.find((h) => h.id === humanId); - const initials = human ? getInitials(human.email) : "?"; + const display = resolveHumanDisplay(humanId, network?.humans); return ( - {initials} + {display.initials} - {human?.email ?? humanId} + {display.email} ); })} @@ -267,7 +266,7 @@ function MembersIndicator({ {shownMembers.map((human) => ( - {getInitials(human.email)} + {resolveHumanDisplay(human.id, humans).initials} ))} @@ -289,19 +288,17 @@ function MembersIndicator({ function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) { const network = useNetwork(networkId); const { onlineHumanIds } = useStreamPresence(); - const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id); - const prefix = creator?.email_prefix ?? particle.created_by_human_id; - const initials = prefix.slice(0, 2).toUpperCase(); + const display = resolveHumanDisplay(particle.created_by_human_id, network?.humans); const isOnline = particle.created_by_human_id ? onlineHumanIds.has(particle.created_by_human_id) : false; return ( - {initials} + {display.initials} - {prefix} - + {display.displayName} - ); } diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index ba8f83e..3bfe371 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -405,7 +405,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { /> ); default: - return ; + return ; } } diff --git a/js/src/firebase.ts b/js/src/firebase.ts index 8825944..a8e931f 100644 --- a/js/src/firebase.ts +++ b/js/src/firebase.ts @@ -1,9 +1,12 @@ import { initializeApp } from 'firebase/app'; +import { getAuth } from "firebase/auth"; import { getFirestore } from "firebase/firestore"; import { appConfig } from "@/config/env"; export const firebaseApp = initializeApp(appConfig.firebase); +export const firebaseAuth = getAuth(firebaseApp); + export const firestoreDb = getFirestore(firebaseApp); // simplifying setup to debug production issues // export const firestoreDb = initializeFirestore(firebaseApp, diff --git a/js/src/hooks/use-invitations.ts b/js/src/hooks/use-member-management.ts similarity index 84% rename from js/src/hooks/use-invitations.ts rename to js/src/hooks/use-member-management.ts index dbf1c42..fb0547f 100644 --- a/js/src/hooks/use-invitations.ts +++ b/js/src/hooks/use-member-management.ts @@ -50,3 +50,13 @@ export function useRevokeInvitation(networkId: string) { }, }); } + +export function useRemoveMember(networkId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (humanId: string) => apiClient.removeMember(networkId, humanId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["networks"] }); + }, + }); +} diff --git a/js/src/hooks/use-stream-autoplay.ts b/js/src/hooks/use-stream-autoplay.ts index 7a74433..16f8235 100644 --- a/js/src/hooks/use-stream-autoplay.ts +++ b/js/src/hooks/use-stream-autoplay.ts @@ -4,7 +4,7 @@ import type { Network, Particle, StreamProperties } from "@/api/types"; import { apiClient } from "@/api/client"; import { useAuthStore } from "@/stores/auth-store"; import { useAutoplayStore } from "@/stores/autoplay-store"; -import { getInitials } from "@/lib/utils"; +import { resolveHumanDisplay } from "@/lib/humans"; /** * Triggers autoplay when a stream's latest child changes to a new media particle. @@ -44,9 +44,10 @@ export function useStreamAutoplay( if (latestChild.type !== "media") return; const particle = latestChild; - const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id); - const senderName = creator?.email_prefix ?? particle.created_by_human_id; - const senderInitials = creator ? getInitials(creator.email) : particle.created_by_human_id.slice(0, 2).toUpperCase(); + const { displayName, initials } = resolveHumanDisplay( + particle.created_by_human_id, + network?.humans, + ); apiClient.getParticleDownloadUrl(particle.properties.object_id).then((downloadUrl) => { window.electronAutoplay.play({ @@ -56,8 +57,8 @@ export function useStreamAutoplay( downloadUrl, mimeType: particle.properties.mime_type, durationMs: particle.properties.duration_ms, - senderName, - senderInitials, + senderName: displayName, + senderInitials: initials, }); }).catch(() => { // Failed to get download URL — skip autoplay silently diff --git a/js/src/lib/humans.ts b/js/src/lib/humans.ts new file mode 100644 index 0000000..543fdb6 --- /dev/null +++ b/js/src/lib/humans.ts @@ -0,0 +1,43 @@ +import type { Human } from "@/api/types"; +import { getInitials } from "@/lib/utils"; + +export const REMOVED_MEMBER_LABEL = "Removed member"; +export const REMOVED_MEMBER_INITIALS = "–"; + +export interface HumanDisplay { + /** True when the human was found in the provided list. */ + exists: boolean; + /** Short name for inline text (e.g. message sender). */ + displayName: string; + /** Full email or fallback label for tooltips. */ + email: string; + /** Initials for avatar fallback. */ + initials: string; +} + +/** + * Resolve a human's display info by id, falling back consistently when the + * human has been removed from the network. Member content (particles, reactions, + * etc.) is retained after removal, so every render path needs a graceful fallback + * instead of leaking raw ids into the UI. + */ +export function resolveHumanDisplay( + humanId: string | null | undefined, + humans: Human[] | undefined, +): HumanDisplay { + const human = humanId ? humans?.find((h) => h.id === humanId) : undefined; + if (!human) { + return { + exists: false, + displayName: REMOVED_MEMBER_LABEL, + email: REMOVED_MEMBER_LABEL, + initials: REMOVED_MEMBER_INITIALS, + }; + } + return { + exists: true, + displayName: human.email_prefix, + email: human.email, + initials: getInitials(human.email), + }; +} diff --git a/js/src/stores/auth-store.ts b/js/src/stores/auth-store.ts index 252b5bd..426cb20 100644 --- a/js/src/stores/auth-store.ts +++ b/js/src/stores/auth-store.ts @@ -1,8 +1,19 @@ import { create } from "zustand"; +import { signInWithCustomToken, signOut as firebaseSignOut } from "firebase/auth"; import { apiClient, ApiError } from "@/api/client"; import type { Human } from "@/api/types"; +import { firebaseAuth } from "@/firebase"; import { useSessionStore } from "./session-store"; +async function signInToFirebase() { + try { + const { token } = await apiClient.getFirebaseToken(); + await signInWithCustomToken(firebaseAuth, token); + } catch (e) { + console.error("Failed to sign in to Firebase", e); + } +} + type AuthStatus = "idle" | "restoring" | "unauthenticated" | "authenticated"; interface AuthState { @@ -37,6 +48,7 @@ export const useAuthStore = create((set) => ({ set({ status: "restoring" }); try { const user = await apiClient.me(); + await signInToFirebase(); set({ status: "authenticated", user }); } catch { useSessionStore.getState().clearToken(); @@ -63,6 +75,7 @@ export const useAuthStore = create((set) => ({ try { const { human, token } = await apiClient.signIn({ email, code }); useSessionStore.getState().setToken(token); + await signInToFirebase(); set({ status: "authenticated", user: human }); } catch (e) { const message = @@ -81,6 +94,9 @@ export const useAuthStore = create((set) => ({ } catch { // Best-effort — sign out locally regardless } finally { + await firebaseSignOut(firebaseAuth).catch((e) => + console.error("Firebase sign-out failed", e), + ); useSessionStore.getState().clearToken(); set({ status: "unauthenticated",