security: access control for particles (#169)
* setup firebase custom token * docs * docs * feat: allow admin removing members from a network * fix: properly handle fallback avatar and names This is especially helpful in the case of members who were removed from a network
This commit was merged in pull request #169.
This commit is contained in:
@@ -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"]
|
||||||
@@ -68,7 +68,7 @@ func main() {
|
|||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
humanSvc := human.NewService(db.Pool())
|
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")
|
slog.Info("starting email notification cycle")
|
||||||
if err := runNotificationCycle(ctx, firestoreClient, aeroSvc, pusherSvc, humanSvc, networkSvc); err != nil {
|
if err := runNotificationCycle(ctx, firestoreClient, aeroSvc, pusherSvc, humanSvc, networkSvc); err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+24
-11
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"cloud.google.com/go/firestore"
|
"cloud.google.com/go/firestore"
|
||||||
"cloud.google.com/go/storage"
|
"cloud.google.com/go/storage"
|
||||||
|
firebase "firebase.google.com/go/v4"
|
||||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
"github.com/flowy-live/llink/internal"
|
"github.com/flowy-live/llink/internal"
|
||||||
"github.com/flowy-live/llink/internal/auth"
|
"github.com/flowy-live/llink/internal/auth"
|
||||||
@@ -62,7 +63,19 @@ func main() {
|
|||||||
defer aeroServer.Close()
|
defer aeroServer.Close()
|
||||||
aeroSvc := pbaero.NewPrimaryClient(aeroServer)
|
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())
|
humanSvc := human.NewService(db.Pool())
|
||||||
|
|
||||||
billingSvc, err := billing.NewService(ctx, db.Pool(), billing.Config{
|
billingSvc, err := billing.NewService(ctx, db.Pool(), billing.Config{
|
||||||
@@ -78,7 +91,14 @@ func main() {
|
|||||||
os.Exit(1)
|
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)
|
particleSvc := particle.NewService(db.Pool(), networkSvc)
|
||||||
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
|
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
|
||||||
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
||||||
@@ -87,14 +107,6 @@ func main() {
|
|||||||
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
|
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
|
||||||
livekitClient := livekit.NewClient()
|
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)
|
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, livekitClient, firestoreClient)
|
||||||
|
|
||||||
withAuth := func(hf http.HandlerFunc) http.Handler {
|
withAuth := func(hf http.HandlerFunc) http.Handler {
|
||||||
@@ -125,6 +137,7 @@ func main() {
|
|||||||
// Auth
|
// Auth
|
||||||
mux.Handle("POST /auth/sign-out", withAuth(h.SignOut))
|
mux.Handle("POST /auth/sign-out", withAuth(h.SignOut))
|
||||||
mux.Handle("GET /auth/me", withAuth(h.GetCurrentHuman))
|
mux.Handle("GET /auth/me", withAuth(h.GetCurrentHuman))
|
||||||
|
mux.Handle("POST /auth/firebase-token", withAuth(h.FirebaseToken))
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
|
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", withAuth(h.ListNetworks))
|
||||||
mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork))
|
mux.Handle("GET /networks/{id}", withAuth(h.GetNetwork))
|
||||||
mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork))
|
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)
|
// Billing (network admin only; admin check happens inside each handler)
|
||||||
mux.Handle("GET /networks/{id}/billing", withAuth(h.GetNetworkBilling))
|
mux.Handle("GET /networks/{id}/billing", withAuth(h.GetNetworkBilling))
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ func main() {
|
|||||||
pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher)
|
pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
authSvc := auth.NewAuthService(authRedis, nil) // nil aeroSvc — pusher only calls GetSession
|
authSvc := auth.NewAuthService(authRedis, nil, nil) // nil aeroSvc / fbAuth — pusher only calls GetSession
|
||||||
networkSvc := network.NewService(db.Pool(), nil, billing.Noop()) // nil aeroSvc / noop billing — pusher never mutates membership
|
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)
|
// Pod identity (use hostname in k8s, which is the pod name)
|
||||||
podID, err := os.Hostname()
|
podID, err := os.Hostname()
|
||||||
|
|||||||
@@ -37,10 +37,12 @@ require (
|
|||||||
cloud.google.com/go/longrunning v0.7.0 // indirect
|
cloud.google.com/go/longrunning v0.7.0 // indirect
|
||||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||||
dario.cat/mergo v1.0.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/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/detectors/gcp v1.30.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.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/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/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||||
github.com/benbjohnson/clock v1.3.5 // 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-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/gofrs/uuid/v5 v5.2.0 // 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/cel-go v0.27.0 // indirect
|
||||||
github.com/google/s2a-go v0.1.9 // indirect
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // 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/text v0.34.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/api v0.256.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 v0.0.0-20250922171735-9219d122eba9 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // 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
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||||
|
|||||||
@@ -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=
|
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 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
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 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
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=
|
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/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 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
|
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/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 h1:qw1GMx6/y8vhVsx626ImfKMuS5CvJmhIKKtuyvfajMM=
|
||||||
github.com/gofrs/uuid/v5 v5.2.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
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 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
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 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
|
||||||
github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
|
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.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.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
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.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.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.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.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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
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=
|
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 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
|
||||||
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
|
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 h1:LvZVVaPE0JSqL+ZWb6ErZfnEOKIqqFWUJE2D0fObSmc=
|
||||||
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc=
|
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=
|
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/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 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
firebaseauth "firebase.google.com/go/v4/auth"
|
||||||
"github.com/flowy-live/llink/genproto/aero"
|
"github.com/flowy-live/llink/genproto/aero"
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
@@ -56,16 +57,27 @@ type AuthService interface {
|
|||||||
ExtendSession(ctx context.Context, sessionToken string) error
|
ExtendSession(ctx context.Context, sessionToken string) error
|
||||||
SignOut(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
|
IsSystemAdmin(ctx context.Context, email string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type authServiceImpl struct {
|
type authServiceImpl struct {
|
||||||
redisClient *redis.Client
|
redisClient *redis.Client
|
||||||
aeroSvc pbaero.PrimaryClient
|
aeroSvc pbaero.PrimaryClient
|
||||||
|
fbAuth *firebaseauth.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient) AuthService {
|
func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient, fbAuth *firebaseauth.Client) AuthService {
|
||||||
return &authServiceImpl{redisClient: redisClient, aeroSvc: aeroSvc}
|
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 {
|
func (a *authServiceImpl) IsSystemAdmin(ctx context.Context, email string) bool {
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ type SignInResponse struct {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FirebaseTokenResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
// Network Request DTOs
|
// Network Request DTOs
|
||||||
|
|
||||||
type CreateNetworkRequest struct {
|
type CreateNetworkRequest struct {
|
||||||
@@ -240,6 +244,27 @@ func (h *Handler) SignIn(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(resp)
|
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
|
// SignOut deletes the session from the token in headers
|
||||||
func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) SignOut(w http.ResponseWriter, r *http.Request) {
|
||||||
token := extractBearerToken(r)
|
token := extractBearerToken(r)
|
||||||
@@ -521,34 +546,28 @@ func (h *Handler) AddMembersToNetwork(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(resp)
|
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) {
|
func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request) {
|
||||||
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
net, _, ok := h.loadNetworkForAdmin(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
networkID := r.PathValue("id")
|
|
||||||
memberHumanId := r.PathValue("humanId")
|
memberHumanId := r.PathValue("humanId")
|
||||||
if networkID == "" || memberHumanId == "" {
|
if memberHumanId == "" {
|
||||||
http.Error(w, "network id and member humanId are required", http.StatusBadRequest)
|
http.Error(w, "member humanId is required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId)
|
if memberHumanId == net.AdminHumanId {
|
||||||
if err != nil {
|
http.Error(w, "admin cannot remove themselves", http.StatusConflict)
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.networkSvc.RemoveMember(r.Context(), networkID, memberHumanId); err != nil {
|
if err := h.networkSvc.RemoveMember(r.Context(), net.ID, memberHumanId); err != nil {
|
||||||
slog.Error("failed to remove member from network", "error", err, "network_id", networkID, "memberHumanId", memberHumanId)
|
slog.Error("failed to remove member from network", "error", err, "network_id", net.ID, "memberHumanId", memberHumanId)
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type repository interface {
|
|||||||
getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
||||||
isMember(ctx context.Context, networkID, humanId string) (bool, error)
|
isMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||||
listAll(ctx context.Context) ([]*Network, error)
|
listAll(ctx context.Context) ([]*Network, error)
|
||||||
|
listAllMemberships(ctx context.Context) (map[string][]string, error)
|
||||||
|
|
||||||
// Invitations
|
// Invitations
|
||||||
createInvitation(ctx context.Context, networkID, email string) error
|
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
|
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) {
|
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) {
|
||||||
rows, err := r.pool.Query(ctx,
|
rows, err := r.pool.Query(ctx,
|
||||||
`SELECT `+networkColumns+` FROM networks`,
|
`SELECT `+networkColumns+` FROM networks`,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"cloud.google.com/go/firestore"
|
||||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
"github.com/flowy-live/llink/internal/billing"
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
@@ -37,6 +38,10 @@ type Service interface {
|
|||||||
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
|
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||||
// ListAll returns all networks with their members
|
// ListAll returns all networks with their members
|
||||||
ListAll(ctx context.Context) ([]*Network, error)
|
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)
|
// Invitations (email-based, for users who haven't registered yet)
|
||||||
InviteByEmail(ctx context.Context, networkID string, emails []string) error
|
InviteByEmail(ctx context.Context, networkID string, emails []string) error
|
||||||
@@ -51,14 +56,19 @@ type serviceImpl struct {
|
|||||||
repo repository
|
repo repository
|
||||||
aeroSvc pbaero.PrimaryClient
|
aeroSvc pbaero.PrimaryClient
|
||||||
billingSvc billing.Service
|
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{
|
return &serviceImpl{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
repo: newRepository(pool),
|
repo: newRepository(pool),
|
||||||
aeroSvc: aeroSvc,
|
aeroSvc: aeroSvc,
|
||||||
billingSvc: billingSvc,
|
billingSvc: billingSvc,
|
||||||
|
fs: fs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,23 +115,66 @@ func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds
|
|||||||
if slices.Contains(humanIds, "") {
|
if slices.Contains(humanIds, "") {
|
||||||
return fmt.Errorf("invalid 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 {
|
||||||
for _, humanId := range humanIds {
|
for _, humanId := range humanIds {
|
||||||
if err := s.repo.addMember(ctx, tx, networkID, humanId); err != nil {
|
if err := s.repo.addMember(ctx, tx, networkID, humanId); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error {
|
||||||
if humanId == "" {
|
if humanId == "" {
|
||||||
return fmt.Errorf("invalid 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)
|
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,
|
// 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)
|
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 {
|
func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
|
||||||
network, err := s.repo.getByID(ctx, networkID)
|
network, err := s.repo.getByID(ctx, networkID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -226,13 +283,18 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu
|
|||||||
return fmt.Errorf("invalid 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 {
|
||||||
err := s.repo.deleteInvitation(ctx, tx, networkID, normalized)
|
err := s.repo.deleteInvitation(ctx, tx, networkID, normalized)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.repo.addMember(ctx, tx, networkID, humanId)
|
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 {
|
func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func newTestService(t *testing.T) network.Service {
|
|||||||
ShootEmail(gomock.Any(), gomock.Any()).
|
ShootEmail(gomock.Any(), gomock.Any()).
|
||||||
Return(&pbaero.ShootEmailResponse{}, nil).
|
Return(&pbaero.ShootEmailResponse{}, nil).
|
||||||
AnyTimes()
|
AnyTimes()
|
||||||
return network.NewService(dbPool, mockAero, billing.Noop())
|
return network.NewService(dbPool, mockAero, billing.Noop(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNetworkService(t *testing.T) {
|
func TestNetworkService(t *testing.T) {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func getStreamStatus(data json.RawMessage) string {
|
|||||||
|
|
||||||
func TestParticleService_CreateAndGet(t *testing.T) {
|
func TestParticleService_CreateAndGet(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network first
|
// Create a network first
|
||||||
@@ -73,7 +73,7 @@ func TestParticleService_CreateAndGet(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_NestedParticles(t *testing.T) {
|
func TestParticleService_NestedParticles(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network
|
// Create a network
|
||||||
@@ -119,7 +119,7 @@ func TestParticleService_NestedParticles(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_CustomVisibility(t *testing.T) {
|
func TestParticleService_CustomVisibility(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network with a member
|
// Create a network with a member
|
||||||
@@ -161,7 +161,7 @@ func TestParticleService_CustomVisibility(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_UpdateAndDelete(t *testing.T) {
|
func TestParticleService_UpdateAndDelete(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network
|
// Create a network
|
||||||
@@ -199,7 +199,7 @@ func TestParticleService_UpdateAndDelete(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_ListRootParticles(t *testing.T) {
|
func TestParticleService_ListRootParticles(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network
|
// Create a network
|
||||||
@@ -225,7 +225,7 @@ func TestParticleService_ListRootParticles(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_OpenCloseStream(t *testing.T) {
|
func TestParticleService_OpenCloseStream(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network
|
// Create a network
|
||||||
@@ -273,7 +273,7 @@ func TestParticleService_OpenCloseStream(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_NotAStream(t *testing.T) {
|
func TestParticleService_NotAStream(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network
|
// Create a network
|
||||||
@@ -302,7 +302,7 @@ func TestParticleService_NotAStream(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_AccessInheritance(t *testing.T) {
|
func TestParticleService_AccessInheritance(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
networkSvc := network.NewService(dbPool, nil, billing.Noop(), nil)
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network with members
|
// Create a network with members
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -128,6 +128,41 @@ profiles:
|
|||||||
---
|
---
|
||||||
apiVersion: skaffold/v4beta11
|
apiVersion: skaffold/v4beta11
|
||||||
kind: Config
|
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:
|
metadata:
|
||||||
name: pusher
|
name: pusher
|
||||||
build:
|
build:
|
||||||
|
|||||||
+11
-2
@@ -5,6 +5,7 @@ import {
|
|||||||
BillingStatusSchema,
|
BillingStatusSchema,
|
||||||
CheckoutSessionResponseSchema,
|
CheckoutSessionResponseSchema,
|
||||||
DepotObjectSchema,
|
DepotObjectSchema,
|
||||||
|
FirebaseTokenResponseSchema,
|
||||||
GetLivekitTokenResponseSchema,
|
GetLivekitTokenResponseSchema,
|
||||||
HumanSchema,
|
HumanSchema,
|
||||||
ListInvitationsResponseSchema,
|
ListInvitationsResponseSchema,
|
||||||
@@ -121,6 +122,14 @@ class ApiClient {
|
|||||||
await this.requestVoid("POST", "/auth/sign-out");
|
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
|
// TODO: security: require passing in the particle id once api deprecates this
|
||||||
async getParticleDownloadUrl(objectId: string): Promise<string> {
|
async getParticleDownloadUrl(objectId: string): Promise<string> {
|
||||||
const response = await this.fetch(
|
const response = await this.fetch(
|
||||||
@@ -183,10 +192,10 @@ class ApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeMember(networkId: string, email: string): Promise<void> {
|
async removeMember(networkId: string, humanId: string): Promise<void> {
|
||||||
await this.requestVoid(
|
await this.requestVoid(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
`/networks/${networkId}/members/${email}`,
|
`/networks/${networkId}/members/${humanId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -263,6 +263,11 @@ export const SignInResponseSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
|
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
|
||||||
|
|
||||||
|
export const FirebaseTokenResponseSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
export type FirebaseTokenResponse = z.infer<typeof FirebaseTokenResponseSchema>;
|
||||||
|
|
||||||
// --- Billing types ---
|
// --- Billing types ---
|
||||||
|
|
||||||
export const BillingCadenceSchema = z.enum(["monthly", "annual"]);
|
export const BillingCadenceSchema = z.enum(["monthly", "annual"]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Human } from "@/api/types";
|
import type { Human } from "@/api/types";
|
||||||
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
import type { ComposingUser } from "@/features/particles/stream-presence-context";
|
import type { ComposingUser } from "@/features/particles/stream-presence-context";
|
||||||
|
|
||||||
interface ComposingIndicatorProps {
|
interface ComposingIndicatorProps {
|
||||||
@@ -22,8 +23,7 @@ export function ComposingIndicator({
|
|||||||
style={{ writingMode: "vertical-rl" }}
|
style={{ writingMode: "vertical-rl" }}
|
||||||
>
|
>
|
||||||
{users.map((u) => {
|
{users.map((u) => {
|
||||||
const human = networkHumans?.find((h) => h.id === u.humanId);
|
const { displayName } = resolveHumanDisplay(u.humanId, networkHumans);
|
||||||
const name = human?.email_prefix ?? u.humanId;
|
|
||||||
const modeLabel = u.mode === "typing" ? "typing" : "recording";
|
const modeLabel = u.mode === "typing" ? "typing" : "recording";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -37,7 +37,7 @@ export function ComposingIndicator({
|
|||||||
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:300ms]" />
|
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:300ms]" />
|
||||||
</span>
|
</span>
|
||||||
<span className="whitespace-nowrap text-[10px] text-white/50">
|
<span className="whitespace-nowrap text-[10px] text-white/50">
|
||||||
{name} {modeLabel}
|
{displayName} {modeLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<div className="fixed inset-0 z-[100]">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
||||||
|
<span className="text-xs text-white/30">
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
Esc
|
||||||
|
</kbd>{" "}
|
||||||
|
to close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-white/60">{description}</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose} disabled={isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? pendingLabel : confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { useNetworks } from "@/hooks/use-networks";
|
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 { apiClient } from "@/api/client";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import type { Network, Invitation } from "@/api/types";
|
import type { Network, Invitation } from "@/api/types";
|
||||||
|
|||||||
@@ -15,12 +15,22 @@ import {
|
|||||||
useNetworkInvitations,
|
useNetworkInvitations,
|
||||||
useInviteMembers,
|
useInviteMembers,
|
||||||
useRevokeInvitation,
|
useRevokeInvitation,
|
||||||
} from "@/hooks/use-invitations";
|
useRemoveMember,
|
||||||
|
} from "@/hooks/use-member-management";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { BillingSection } from "@/features/network-billing";
|
import { BillingSection } from "@/features/network-billing";
|
||||||
|
import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay";
|
||||||
import type { Human } from "@/api/types";
|
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();
|
const initials = human.email_prefix.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -40,6 +50,17 @@ function MemberRow({ human, isAdmin }: { human: Human; isAdmin: boolean }) {
|
|||||||
Admin
|
Admin
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{onRemove && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="text-muted-foreground hover:text-destructive shrink-0"
|
||||||
|
aria-label={`Remove ${human.email}`}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -170,6 +191,8 @@ export default function NetworkSettingsPage() {
|
|||||||
const { data: invitations } = useNetworkInvitations(networkId!);
|
const { data: invitations } = useNetworkInvitations(networkId!);
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
const isAdmin = currentUser?.id === network?.admin_human.id;
|
const isAdmin = currentUser?.id === network?.admin_human.id;
|
||||||
|
const [memberToRemove, setMemberToRemove] = useState<Human | null>(null);
|
||||||
|
const removeMember = useRemoveMember(networkId!);
|
||||||
|
|
||||||
const billingRef = useRef<HTMLDivElement>(null);
|
const billingRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -228,17 +251,23 @@ export default function NetworkSettingsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Separator />
|
<Separator />
|
||||||
{network?.humans.map((human, index) => (
|
{network?.humans.map((human, index) => {
|
||||||
<div key={human.id}>
|
const isRowAdmin = human.id === network.admin_human.id;
|
||||||
<MemberRow
|
const canRemove =
|
||||||
human={human}
|
isAdmin && !isRowAdmin && human.id !== currentUser?.id;
|
||||||
isAdmin={human.id === network.admin_human.id}
|
return (
|
||||||
/>
|
<div key={human.id}>
|
||||||
{index < network.humans.length - 1 && (
|
<MemberRow
|
||||||
<Separator className="mx-4" />
|
human={human}
|
||||||
)}
|
isAdmin={isRowAdmin}
|
||||||
</div>
|
onRemove={canRemove ? () => setMemberToRemove(human) : undefined}
|
||||||
))}
|
/>
|
||||||
|
{index < network.humans.length - 1 && (
|
||||||
|
<Separator className="mx-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{isAdmin && network && (
|
{isAdmin && network && (
|
||||||
@@ -299,6 +328,42 @@ export default function NetworkSettingsPage() {
|
|||||||
|
|
||||||
<div className="h-6" />
|
<div className="h-6" />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
{memberToRemove && (
|
||||||
|
<ConfirmDestructiveOverlay
|
||||||
|
title={`Remove ${memberToRemove.email_prefix}?`}
|
||||||
|
description={
|
||||||
|
<ul className="list-disc space-y-1 pl-4">
|
||||||
|
<li>
|
||||||
|
They'll lose access to this network's streams and files within
|
||||||
|
seconds.
|
||||||
|
</li>
|
||||||
|
<li>Any content they posted stays in the network.</li>
|
||||||
|
<li>
|
||||||
|
If they're in a live huddle, they may remain until the call ends.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { ConfirmDestructiveOverlay } from "@/components/confirm-destructive-overlay";
|
||||||
import { softDeleteParticle } from "@/lib/firestore-particles";
|
import { softDeleteParticle } from "@/lib/firestore-particles";
|
||||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
@@ -40,55 +39,20 @@ export function DeleteParticleOverlay({
|
|||||||
}
|
}
|
||||||
}, [deleting, networkId, onClose, particle.id, streamId, userId]);
|
}, [deleting, networkId, onClose, particle.id, streamId, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
const handler = (e: KeyboardEvent) => {
|
<ConfirmDestructiveOverlay
|
||||||
if (e.key === "Escape") {
|
title="Delete this particle?"
|
||||||
e.preventDefault();
|
description={
|
||||||
e.stopPropagation();
|
<p>
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handler, { capture: true });
|
|
||||||
return () => window.removeEventListener("keydown", handler, { capture: true });
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div className="fixed inset-0 z-[100]">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<h2 className="text-sm font-semibold text-white/70">Delete this particle?</h2>
|
|
||||||
<span className="text-xs text-white/30">
|
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Esc
|
|
||||||
</kbd>{" "}
|
|
||||||
to close
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-white/60">
|
|
||||||
This cannot be undone. Other viewers will see a "This particle was
|
This cannot be undone. Other viewers will see a "This particle was
|
||||||
deleted" message in its place.
|
deleted" message in its place.
|
||||||
</p>
|
</p>
|
||||||
|
}
|
||||||
<div className="mt-5 flex items-center justify-end gap-2">
|
confirmLabel="Delete"
|
||||||
<Button variant="ghost" size="sm" onClick={onClose} disabled={deleting}>
|
pendingLabel="Deleting…"
|
||||||
Cancel
|
isPending={deleting}
|
||||||
</Button>
|
onConfirm={handleDelete}
|
||||||
<Button
|
onClose={onClose}
|
||||||
variant="destructive"
|
/>
|
||||||
size="sm"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleting}
|
|
||||||
>
|
|
||||||
{deleting ? "Deleting…" : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
|||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from "@/hooks/use-networks";
|
||||||
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
|
|
||||||
// How long to linger on a tombstone before auto-advancing. Matches the
|
// How long to linger on a tombstone before auto-advancing. Matches the
|
||||||
// "reading" cadence of a short text particle.
|
// "reading" cadence of a short text particle.
|
||||||
@@ -24,8 +25,8 @@ export function DeletedParticleView({
|
|||||||
const deleterId =
|
const deleterId =
|
||||||
"deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined;
|
"deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined;
|
||||||
const deleter = deleterId
|
const deleter = deleterId
|
||||||
? network?.humans?.find((h) => h.id === deleterId)
|
? resolveHumanDisplay(deleterId, network?.humans)
|
||||||
: undefined;
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused) return;
|
if (paused) return;
|
||||||
@@ -42,7 +43,7 @@ export function DeletedParticleView({
|
|||||||
This particle was deleted
|
This particle was deleted
|
||||||
</p>
|
</p>
|
||||||
{deleter && (
|
{deleter && (
|
||||||
<p className="text-white/40 text-xs">by {deleter.email_prefix}</p>
|
<p className="text-white/40 text-xs">by {deleter.displayName}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { FileIcon, HelpCircleIcon, ScrollTextIcon, BookOpenIcon } from "lucide-react";
|
import { FileIcon, HelpCircleIcon, ScrollTextIcon, BookOpenIcon } from "lucide-react";
|
||||||
|
import { useNetwork } from "@/hooks/use-networks";
|
||||||
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
|
|
||||||
const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
|
const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
|
||||||
quest: { icon: ScrollTextIcon, label: "Quest" },
|
quest: { icon: ScrollTextIcon, label: "Quest" },
|
||||||
@@ -16,9 +18,12 @@ const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
|
|||||||
|
|
||||||
interface FallbackParticleViewProps {
|
interface FallbackParticleViewProps {
|
||||||
particle: Particle;
|
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] ?? {
|
const meta = TYPE_META[particle.type] ?? {
|
||||||
icon: HelpCircleIcon,
|
icon: HelpCircleIcon,
|
||||||
label: particle.type,
|
label: particle.type,
|
||||||
@@ -51,7 +56,7 @@ export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
From {particle.created_by_human_id}
|
From {creator.displayName}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useLiveLatestChild } from "@/hooks/use-particle";
|
|||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { particlePath } from "@/lib/particle-path";
|
import { particlePath } from "@/lib/particle-path";
|
||||||
import { getInitials } from "@/lib/utils";
|
import { getInitials } from "@/lib/utils";
|
||||||
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -174,9 +175,11 @@ const StreamRow = memo(function StreamRow({
|
|||||||
}
|
}
|
||||||
// Group stream
|
// Group stream
|
||||||
if (isCurrentUser) return "You: ";
|
if (isCurrentUser) return "You: ";
|
||||||
const creator = network?.humans?.find((h) => h.id === latestChild.created_by_human_id);
|
const { displayName } = resolveHumanDisplay(
|
||||||
const name = creator?.email_prefix ?? latestChild.created_by_human_id;
|
latestChild.created_by_human_id,
|
||||||
const capitalized = name.charAt(0).toUpperCase() + name.slice(1);
|
network?.humans,
|
||||||
|
);
|
||||||
|
const capitalized = displayName.charAt(0).toUpperCase() + displayName.slice(1);
|
||||||
return `${capitalized}: `;
|
return `${capitalized}: `;
|
||||||
}, [latestChild, userId, isDM, network]);
|
}, [latestChild, userId, isDM, network]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useLiveParticle } from "@/hooks/use-particle";
|
||||||
import { particlePath } from "@/lib/particle-path";
|
import { particlePath } from "@/lib/particle-path";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
import { StreamView } from "@/features/particles/stream-view";
|
import { StreamView } from "@/features/particles/stream-view";
|
||||||
import { FolderView } from "@/features/particles/folder-view";
|
import { FolderView } from "@/features/particles/folder-view";
|
||||||
@@ -26,22 +30,11 @@ export default function ParticleViewResolver() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error || !particle) {
|
||||||
return (
|
// Errors here are almost always Firestore permission-denied — the user lost
|
||||||
<div className="flex h-full items-center justify-center">
|
// access to the network or to a custom-visibility particle. The React Router
|
||||||
<p className="text-destructive text-sm">Failed to load particle</p>
|
// stays on the dead route, so without an explicit escape the user is stuck.
|
||||||
</div>
|
return <InaccessibleParticle />;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!particle) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Particle: {segments.join(" / ")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (particle.type) {
|
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 (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
|
||||||
|
<Lock className="text-muted-foreground size-8" />
|
||||||
|
<div className="flex max-w-sm flex-col gap-1">
|
||||||
|
<p className="text-sm font-medium">This particle isn't available</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
It may have been deleted, or your access was removed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => navigate("/", { replace: true })}>
|
||||||
|
Go home
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Plus, X } from "lucide-react";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
import type { Human } from "@/api/types";
|
import type { Human } from "@/api/types";
|
||||||
|
|
||||||
interface ReactionBarProps {
|
interface ReactionBarProps {
|
||||||
@@ -17,10 +18,7 @@ function getReactorNames(
|
|||||||
humans?: Human[],
|
humans?: Human[],
|
||||||
): string {
|
): string {
|
||||||
return humanIds
|
return humanIds
|
||||||
.map((id) => {
|
.map((id) => resolveHumanDisplay(id, humans).displayName)
|
||||||
const human = humans?.find((h) => h.id === id);
|
|
||||||
return human?.email_prefix ?? id;
|
|
||||||
})
|
|
||||||
.join(", ");
|
.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { updateParticleVisibleTo } from "@/lib/firestore-particles";
|
|||||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from "@/hooks/use-networks";
|
||||||
import { cn, getInitials } from "@/lib/utils";
|
import { cn, getInitials } from "@/lib/utils";
|
||||||
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store";
|
import { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store";
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ export function StreamMembersOverlay({
|
|||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<ul className="flex flex-col gap-0.5 pr-2">
|
<ul className="flex flex-col gap-0.5 pr-2">
|
||||||
{memberIds.map((id) => {
|
{memberIds.map((id) => {
|
||||||
const human = humans.find((h) => h.id === id);
|
const display = resolveHumanDisplay(id, humans);
|
||||||
const isCreatorRow = id === creatorId;
|
const isCreatorRow = id === creatorId;
|
||||||
const canRemove =
|
const canRemove =
|
||||||
isCreator && visibility.mode === "custom" && !isCreatorRow;
|
isCreator && visibility.mode === "custom" && !isCreatorRow;
|
||||||
@@ -169,11 +170,16 @@ export function StreamMembersOverlay({
|
|||||||
>
|
>
|
||||||
<Avatar size="sm">
|
<Avatar size="sm">
|
||||||
<AvatarFallback className="text-[10px]">
|
<AvatarFallback className="text-[10px]">
|
||||||
{human ? getInitials(human.email) : "?"}
|
{display.initials}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="flex-1 truncate">
|
<span
|
||||||
{human?.email_prefix ?? id}
|
className={cn(
|
||||||
|
"flex-1 truncate",
|
||||||
|
!display.exists && "italic text-white/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{display.displayName}
|
||||||
</span>
|
</span>
|
||||||
{isCreatorRow && (
|
{isCreatorRow && (
|
||||||
<span className="text-[10px] uppercase tracking-wider text-white/30">
|
<span className="text-[10px] uppercase tracking-wider text-white/30">
|
||||||
@@ -185,7 +191,7 @@ export function StreamMembersOverlay({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeMember(id)}
|
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"
|
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}`}
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbS
|
|||||||
import { WindowControls } from "@/components/window-controls";
|
import { WindowControls } from "@/components/window-controls";
|
||||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||||
import { useStreamPresence } from "@/features/particles/stream-presence-context";
|
import { useStreamPresence } from "@/features/particles/stream-presence-context";
|
||||||
import { getInitials } from "@/lib/utils";
|
import { resolveHumanDisplay } from "@/lib/humans";
|
||||||
|
|
||||||
function getParticleDisplayName(particle: Particle): string {
|
function getParticleDisplayName(particle: Particle): string {
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
@@ -112,18 +112,17 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
|||||||
</span>
|
</span>
|
||||||
<AvatarGroup>
|
<AvatarGroup>
|
||||||
{huddleParticipants.map((humanId) => {
|
{huddleParticipants.map((humanId) => {
|
||||||
const human = network?.humans?.find((h) => h.id === humanId);
|
const display = resolveHumanDisplay(humanId, network?.humans);
|
||||||
const initials = human ? getInitials(human.email) : "?";
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={humanId}>
|
<Tooltip key={humanId}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Avatar size="sm">
|
<Avatar size="sm">
|
||||||
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
|
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
|
||||||
{initials}
|
{display.initials}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{human?.email ?? humanId}</TooltipContent>
|
<TooltipContent>{display.email}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -267,7 +266,7 @@ function MembersIndicator({
|
|||||||
{shownMembers.map((human) => (
|
{shownMembers.map((human) => (
|
||||||
<Avatar key={human.id} size="sm">
|
<Avatar key={human.id} size="sm">
|
||||||
<AvatarFallback className="text-[8px]">
|
<AvatarFallback className="text-[8px]">
|
||||||
{getInitials(human.email)}
|
{resolveHumanDisplay(human.id, humans).initials}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
))}
|
))}
|
||||||
@@ -289,19 +288,17 @@ function MembersIndicator({
|
|||||||
function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) {
|
function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) {
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
const { onlineHumanIds } = useStreamPresence();
|
const { onlineHumanIds } = useStreamPresence();
|
||||||
const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id);
|
const display = resolveHumanDisplay(particle.created_by_human_id, network?.humans);
|
||||||
const prefix = creator?.email_prefix ?? particle.created_by_human_id;
|
|
||||||
const initials = prefix.slice(0, 2).toUpperCase();
|
|
||||||
const isOnline = particle.created_by_human_id ? onlineHumanIds.has(particle.created_by_human_id) : false;
|
const isOnline = particle.created_by_human_id ? onlineHumanIds.has(particle.created_by_human_id) : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Avatar size="sm" className={isOnline ? "ring-2 ring-green-500" : ""}>
|
<Avatar size="sm" className={isOnline ? "ring-2 ring-green-500" : ""}>
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{initials}
|
{display.initials}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{prefix} - <RelativeTimestamp date={particle.created_at} />
|
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return <FallbackParticleView particle={particle} />;
|
return <FallbackParticleView particle={particle} networkId={networkId} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { initializeApp } from 'firebase/app';
|
import { initializeApp } from 'firebase/app';
|
||||||
|
import { getAuth } from "firebase/auth";
|
||||||
import { getFirestore } from "firebase/firestore";
|
import { getFirestore } from "firebase/firestore";
|
||||||
import { appConfig } from "@/config/env";
|
import { appConfig } from "@/config/env";
|
||||||
|
|
||||||
export const firebaseApp = initializeApp(appConfig.firebase);
|
export const firebaseApp = initializeApp(appConfig.firebase);
|
||||||
|
|
||||||
|
export const firebaseAuth = getAuth(firebaseApp);
|
||||||
|
|
||||||
export const firestoreDb = getFirestore(firebaseApp);
|
export const firestoreDb = getFirestore(firebaseApp);
|
||||||
// simplifying setup to debug production issues
|
// simplifying setup to debug production issues
|
||||||
// export const firestoreDb = initializeFirestore(firebaseApp,
|
// export const firestoreDb = initializeFirestore(firebaseApp,
|
||||||
|
|||||||
@@ -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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import type { Network, Particle, StreamProperties } from "@/api/types";
|
|||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAutoplayStore } from "@/stores/autoplay-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.
|
* 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;
|
if (latestChild.type !== "media") return;
|
||||||
|
|
||||||
const particle = latestChild;
|
const particle = latestChild;
|
||||||
const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id);
|
const { displayName, initials } = resolveHumanDisplay(
|
||||||
const senderName = creator?.email_prefix ?? particle.created_by_human_id;
|
particle.created_by_human_id,
|
||||||
const senderInitials = creator ? getInitials(creator.email) : particle.created_by_human_id.slice(0, 2).toUpperCase();
|
network?.humans,
|
||||||
|
);
|
||||||
|
|
||||||
apiClient.getParticleDownloadUrl(particle.properties.object_id).then((downloadUrl) => {
|
apiClient.getParticleDownloadUrl(particle.properties.object_id).then((downloadUrl) => {
|
||||||
window.electronAutoplay.play({
|
window.electronAutoplay.play({
|
||||||
@@ -56,8 +57,8 @@ export function useStreamAutoplay(
|
|||||||
downloadUrl,
|
downloadUrl,
|
||||||
mimeType: particle.properties.mime_type,
|
mimeType: particle.properties.mime_type,
|
||||||
durationMs: particle.properties.duration_ms,
|
durationMs: particle.properties.duration_ms,
|
||||||
senderName,
|
senderName: displayName,
|
||||||
senderInitials,
|
senderInitials: initials,
|
||||||
});
|
});
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// Failed to get download URL — skip autoplay silently
|
// Failed to get download URL — skip autoplay silently
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { signInWithCustomToken, signOut as firebaseSignOut } from "firebase/auth";
|
||||||
import { apiClient, ApiError } from "@/api/client";
|
import { apiClient, ApiError } from "@/api/client";
|
||||||
import type { Human } from "@/api/types";
|
import type { Human } from "@/api/types";
|
||||||
|
import { firebaseAuth } from "@/firebase";
|
||||||
import { useSessionStore } from "./session-store";
|
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";
|
type AuthStatus = "idle" | "restoring" | "unauthenticated" | "authenticated";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -37,6 +48,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
set({ status: "restoring" });
|
set({ status: "restoring" });
|
||||||
try {
|
try {
|
||||||
const user = await apiClient.me();
|
const user = await apiClient.me();
|
||||||
|
await signInToFirebase();
|
||||||
set({ status: "authenticated", user });
|
set({ status: "authenticated", user });
|
||||||
} catch {
|
} catch {
|
||||||
useSessionStore.getState().clearToken();
|
useSessionStore.getState().clearToken();
|
||||||
@@ -63,6 +75,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
try {
|
try {
|
||||||
const { human, token } = await apiClient.signIn({ email, code });
|
const { human, token } = await apiClient.signIn({ email, code });
|
||||||
useSessionStore.getState().setToken(token);
|
useSessionStore.getState().setToken(token);
|
||||||
|
await signInToFirebase();
|
||||||
set({ status: "authenticated", user: human });
|
set({ status: "authenticated", user: human });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message =
|
const message =
|
||||||
@@ -81,6 +94,9 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
} catch {
|
} catch {
|
||||||
// Best-effort — sign out locally regardless
|
// Best-effort — sign out locally regardless
|
||||||
} finally {
|
} finally {
|
||||||
|
await firebaseSignOut(firebaseAuth).catch((e) =>
|
||||||
|
console.error("Firebase sign-out failed", e),
|
||||||
|
);
|
||||||
useSessionStore.getState().clearToken();
|
useSessionStore.getState().clearToken();
|
||||||
set({
|
set({
|
||||||
status: "unauthenticated",
|
status: "unauthenticated",
|
||||||
|
|||||||
Reference in New Issue
Block a user