webapp: organizations behind a Directory seam
The hub already abstracted authentication — AuthProvider, with BuiltinAuth as the built-in implementation — and then reached around that seam three times: Accounts() was declared on neither interface, admin.go type-asserted *BuiltinAuth (five handlers silently degraded to 404/empty under any other provider), and organizations were not on the seam at all. That last gap had teeth. A deployment whose identities come from elsewhere had no way to own its orgs, so the code that did own them wrote into the hub's OrgDB from the side — and nothing stopped the hub from inventing an org that the identity system had never heard of. One did: a hub-created org held every project while the mirrored one sat empty, and no sync path could see the difference. Directory (directory.go) is where organizations live now. LocalDirectory wraps today's OrgDB unchanged — same last-owner protection, same normEmail, same "o-"+randHex(4) ids, same file/SQL persistence — so a self-hosted hub behaves exactly as before. A deployment whose orgs are owned elsewhere implements the same interface, returns ErrManagedElsewhere from the write half, and the handlers answer 409 with ManageURL. The hub never learns why a write was refused, only where to send the user. Two rules shape the interface. Reads are on the request path: Role runs on every project request, including the /store/* endpoints a device hits every few seconds with a token that carries no identity claims, so an implementation backed by a remote system answers from its own cache — and that cache is its business, not the hub's. Writes are optional, because "this hub owns its orgs" is a deployment fact, not a code path. - Server.Orgs *OrgDB becomes Server.Dir Directory: 28 call sites, 8 nil-checks, one writeDirErr helper for the 409 translation. - /api/orgs gains manage_url per org — the destination of the account menu's Settings entry. The client follows a link and never branches on which kind of hub it is talking to. - Org administration becomes a real route, /orgs/<id>, retiring one of the two URL-less panels CLAUDE.md grandfathers. When a directory's ManageURL is not hub-local, the SPA fallback redirects there instead — so a hub that cannot administer its orgs cannot paint a console whose every control 409s. - Accounts() moves onto AuthProvider. admin.go's type assertion becomes an optional AccountApprover, and a provider without one now answers 503 rather than an empty approval queue: "no queue here" and "queue is empty" are different answers and only one of them was true. Two reviews drove the rest. The architecture review caught a browser page load that could delete org members (a display read ran the full membership reconcile, and a 200 with an empty user list evicted everyone), one write site that escaped the 409 translation, and a webhook that could wedge an event stream behind an unappliable event. The design review, over eight rounds, caught the org page rendering live controls on a hub that cannot use them, a share link made unrevokable by a long filename, nine keyboard tab stops parked off-screen behind a closed drawer, and — five separate times — a fix of mine that looked right in the source and did nothing in the browser. Conformance tests run both a writable and a read-only implementation against one contract; the seat, prune, and out-of-order regressions each have a test written to fail against the old code.
@@ -319,7 +319,7 @@ credentials); otherwise it is relayed through this server.`,
|
||||
if err != nil {
|
||||
return fmt.Errorf("open org registry: %w", err)
|
||||
}
|
||||
srv.Orgs = orgs
|
||||
srv.Dir = webapp.LocalDirectory{OrgDB: orgs}
|
||||
// Invite links can bootstrap an account on an invite-only hub.
|
||||
auth.InviteValid = orgs.ValidInvite
|
||||
// A hub that predates organizations: sweep its projects into
|
||||
|
||||
@@ -38,7 +38,7 @@ func orgHub(t *testing.T, storage remote.Backend) (ts *httptest.Server, aliceTok
|
||||
srv := &webapp.Server{
|
||||
Root: storage, Projects: db, Refresh: 0,
|
||||
Upload: webapp.UploadConfig{Enabled: true},
|
||||
Auth: auth, Orgs: orgs,
|
||||
Auth: auth, Dir: webapp.LocalDirectory{OrgDB: orgs},
|
||||
}
|
||||
ts = httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
@@ -14,14 +14,14 @@ import (
|
||||
|
||||
// projectOwner returns true when the request's account owns the project's org.
|
||||
func (s *Server) projectOwner(r *http.Request, projectID string) bool {
|
||||
if s.Orgs == nil || s.Auth == nil {
|
||||
if s.Dir == nil || s.Auth == nil {
|
||||
return true
|
||||
}
|
||||
org := s.orgOf(projectID)
|
||||
if org == "" {
|
||||
return true
|
||||
}
|
||||
return s.Orgs.Role(org, s.requestUser(r).Email) == RoleOwner
|
||||
return s.Dir.Role(org, s.requestUser(r).Email) == RoleOwner
|
||||
}
|
||||
|
||||
// handleProjectRename renames a project. Owner of its org only.
|
||||
@@ -80,12 +80,12 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
|
||||
// so an owner can audit "what have we made public?" in one place. Any org
|
||||
// member may view; only owners revoke (via the existing per-share endpoint).
|
||||
func (s *Server) handleOrgShares(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Shares == nil || s.Orgs == nil {
|
||||
if s.Shares == nil || s.Dir == nil {
|
||||
http.Error(w, "sharing is not enabled on this server", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
orgID := r.PathValue("org")
|
||||
if s.Orgs.Role(orgID, s.requestUser(r).Email) == "" {
|
||||
if s.Dir.Role(orgID, s.requestUser(r).Email) == "" {
|
||||
http.Error(w, "you are not a member of this organization", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -103,11 +103,19 @@ func (s *Server) handleOrgShares(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"shares": out})
|
||||
}
|
||||
|
||||
// builtinAuth returns the concrete OSS auth provider, or nil for a swapped
|
||||
// provider that doesn't support approval.
|
||||
func (s *Server) builtinAuth() *BuiltinAuth {
|
||||
a, _ := s.Auth.(*BuiltinAuth)
|
||||
return a
|
||||
// approver returns the auth provider's account-administration half, if it has
|
||||
// one. A provider whose accounts live in an external identity system does not:
|
||||
// there is no local approval queue to show and no local policy to flip.
|
||||
func (s *Server) approver(w http.ResponseWriter) (AccountApprover, bool) {
|
||||
a, ok := s.Auth.(AccountApprover)
|
||||
if !ok {
|
||||
// 503, not an empty list: "no queue here" and "queue is empty" are
|
||||
// different answers, and only one of them is true.
|
||||
http.Error(w, "accounts on this hub are administered in its identity provider",
|
||||
http.StatusServiceUnavailable)
|
||||
return nil, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
|
||||
// handleAdminPending lists accounts awaiting approval. Hub admins only.
|
||||
@@ -116,9 +124,8 @@ func (s *Server) handleAdminPending(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "hub admins only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
a := s.builtinAuth()
|
||||
if a == nil {
|
||||
writeJSON(w, map[string]any{"pending": []any{}})
|
||||
a, ok := s.approver(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"pending": a.PendingUsers()})
|
||||
@@ -133,9 +140,8 @@ func (s *Server) handleAdminPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "hub admins only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
a := s.builtinAuth()
|
||||
if a == nil {
|
||||
http.Error(w, "policy is not supported by this auth provider", http.StatusNotFound)
|
||||
a, ok := s.approver(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
@@ -149,7 +155,7 @@ func (s *Server) handleAdminPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// Email verification is only a real gate with a mailer; refuse to turn
|
||||
// it on without SMTP rather than silently logging links.
|
||||
if req.RequireVerification && a.Mail == nil {
|
||||
if req.RequireVerification && !a.Policy().Mailer {
|
||||
http.Error(w, "email verification needs SMTP configured on the server", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -158,18 +164,7 @@ func (s *Server) handleAdminPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
admins := make([]string, 0, len(a.Admins))
|
||||
for e := range a.Admins {
|
||||
admins = append(admins, e)
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"require_verification": a.RequireVerification,
|
||||
"require_approval": a.RequireApproval,
|
||||
"allow_signup": a.AllowSignup,
|
||||
"allowed_domains": a.AllowedDomains, // read-only (server config)
|
||||
"admins": admins, // read-only (server config)
|
||||
"mailer": a.Mail != nil,
|
||||
})
|
||||
writeJSON(w, a.Policy())
|
||||
}
|
||||
|
||||
// handleAdminApprove activates a pending account. Hub admins only.
|
||||
@@ -178,9 +173,8 @@ func (s *Server) handleAdminApprove(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "hub admins only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
a := s.builtinAuth()
|
||||
if a == nil {
|
||||
http.Error(w, "approval is not supported by this auth provider", http.StatusNotFound)
|
||||
a, ok := s.approver(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.Approve(r.PathValue("id")); err != nil {
|
||||
@@ -196,9 +190,8 @@ func (s *Server) handleAdminDeny(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "hub admins only", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
a := s.builtinAuth()
|
||||
if a == nil {
|
||||
http.Error(w, "approval is not supported by this auth provider", http.StatusNotFound)
|
||||
a, ok := s.approver(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.Deny(r.PathValue("id")); err != nil {
|
||||
|
||||
@@ -31,6 +31,40 @@ type AuthProvider interface {
|
||||
// Register mounts the provider's own pages and endpoints (/auth/*,
|
||||
// /api/auth/*) on the server mux.
|
||||
Register(mux *http.ServeMux)
|
||||
// Accounts lists every account the provider knows, oldest first. Startup
|
||||
// tasks (the org migration) need it, and both implementations already had
|
||||
// it — declaring it here stops callers reaching for a concrete type.
|
||||
Accounts() []User
|
||||
}
|
||||
|
||||
// AccountApprover is the optional half of account administration: signup
|
||||
// policy and the approval queue behind /api/admin/*. A provider whose accounts
|
||||
// live in an external identity system does not implement it, and those routes
|
||||
// say so (503) rather than pretending the queue is empty.
|
||||
type AccountApprover interface {
|
||||
PendingUsers() []User
|
||||
Approve(id string) error
|
||||
Deny(id string) error
|
||||
SetPolicy(requireVerification, requireApproval bool) error
|
||||
// Policy reports the signup gates as configured. The provider assembles
|
||||
// it, so the hub never reaches into provider fields to render the page.
|
||||
Policy() SignupPolicy
|
||||
}
|
||||
|
||||
// Brander is the optional hub-name half: a provider that renders its own
|
||||
// sign-in pages knows what to call this hub.
|
||||
type Brander interface{ Branding() string }
|
||||
|
||||
// SignupPolicy is what /api/admin/policy reports: which gates are on, and
|
||||
// which of them are server-config owned (read-only to a browser session, so
|
||||
// that no one can widen access by clicking).
|
||||
type SignupPolicy struct {
|
||||
RequireVerification bool `json:"require_verification"`
|
||||
RequireApproval bool `json:"require_approval"`
|
||||
AllowSignup bool `json:"allow_signup"`
|
||||
AllowedDomains []string `json:"allowed_domains"` // read-only
|
||||
Admins []string `json:"admins"` // read-only
|
||||
Mailer bool `json:"mailer"` // SMTP configured?
|
||||
}
|
||||
|
||||
// authGate wraps the API with authentication when a provider is configured.
|
||||
|
||||
@@ -400,6 +400,29 @@ func (a *BuiltinAuth) grantDevice(id, userID string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Branding is the hub name this provider renders on its own pages.
|
||||
func (a *BuiltinAuth) Branding() string { return a.Brand }
|
||||
|
||||
// Policy reports this provider's signup gates (webapp.AccountApprover). The
|
||||
// provider assembles it so the hub never reaches into these fields itself.
|
||||
func (a *BuiltinAuth) Policy() SignupPolicy {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
admins := make([]string, 0, len(a.Admins))
|
||||
for e := range a.Admins {
|
||||
admins = append(admins, e)
|
||||
}
|
||||
sort.Strings(admins)
|
||||
return SignupPolicy{
|
||||
RequireVerification: a.RequireVerification,
|
||||
RequireApproval: a.RequireApproval,
|
||||
AllowSignup: a.AllowSignup,
|
||||
AllowedDomains: a.AllowedDomains,
|
||||
Admins: admins,
|
||||
Mailer: a.Mail != nil,
|
||||
}
|
||||
}
|
||||
|
||||
// PendingUsers lists accounts awaiting admin approval, oldest first.
|
||||
func (a *BuiltinAuth) PendingUsers() []User {
|
||||
a.mu.Lock()
|
||||
@@ -606,8 +629,34 @@ font-family:ui-monospace,Menlo,monospace}
|
||||
}
|
||||
|
||||
func field(label, name, typ, value string) string {
|
||||
return fmt.Sprintf(`<label>%s</label><input name=%q type=%q value=%q required>`,
|
||||
html.EscapeString(label), name, typ, html.EscapeString(value))
|
||||
return fmt.Sprintf(`<label for="f-%s">%s</label><input id="f-%s" name=%q type=%q value=%q autocomplete=%q required>`,
|
||||
name, html.EscapeString(label), name, name, typ, html.EscapeString(value), autocompleteFor(name, typ))
|
||||
}
|
||||
|
||||
// autocompleteFor names a field's purpose so browsers and password managers
|
||||
// fill it (WCAG 1.3.5). A password field's purpose depends on the page, not
|
||||
// its name: offering a saved credential on a signup form is worse than
|
||||
// offering nothing, so those callers use newPasswordField.
|
||||
func autocompleteFor(name, typ string) string {
|
||||
switch name {
|
||||
case "email":
|
||||
return "email"
|
||||
case "name":
|
||||
return "name"
|
||||
case "code":
|
||||
return "one-time-code"
|
||||
}
|
||||
if typ == "password" {
|
||||
return "current-password"
|
||||
}
|
||||
return "on"
|
||||
}
|
||||
|
||||
// newPasswordField is field() for a password being CREATED (signup, reset),
|
||||
// so a manager generates one instead of filling an existing login.
|
||||
func newPasswordField(label, name string) string {
|
||||
return fmt.Sprintf(`<label for="f-%s">%s</label><input id="f-%s" name=%q type="password" autocomplete="new-password" required>`,
|
||||
name, html.EscapeString(label), name, name)
|
||||
}
|
||||
|
||||
func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -715,7 +764,7 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
|
||||
field("Name", "name", "text", r.FormValue("name")),
|
||||
field("Email", "email", "email", r.FormValue("email")),
|
||||
domainNote,
|
||||
field("Password (min 8 chars)", "password", "password", ""),
|
||||
newPasswordField("Password (min 8 chars)", "password"),
|
||||
errMsg, url.QueryEscape(next)))
|
||||
}
|
||||
|
||||
@@ -863,7 +912,7 @@ func (a *BuiltinAuth) pageResetConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func resetForm(token, msg string) string {
|
||||
return fmt.Sprintf(`<form method="post"><input type="hidden" name="token" value=%q>%s%s<button>Set password</button></form>`,
|
||||
html.EscapeString(token), field("New password (min 8 chars)", "password", "password", ""), msg)
|
||||
html.EscapeString(token), newPasswordField("New password (min 8 chars)", "password"), msg)
|
||||
}
|
||||
|
||||
func requestBaseURL(r *http.Request) string {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Directory is where a hub's organizations live. The built-in one is this
|
||||
// package's OrgDB (LocalDirectory); a deployment whose users, orgs and
|
||||
// memberships are owned by an external identity system supplies its own,
|
||||
// alongside its AuthProvider.
|
||||
//
|
||||
// Two rules shape this interface:
|
||||
//
|
||||
// Reads are on the request path. Role is called for every project request,
|
||||
// including the /store/* sync endpoints a device hits every few seconds with
|
||||
// a device token that carries no identity claims. An implementation backed by
|
||||
// a remote system therefore has to answer Role from a local cache, and that
|
||||
// cache is the implementation's business — the hub does not keep one, does not
|
||||
// refresh one, and must never be written to from the side. (It used to be:
|
||||
// the hub owned the mirror and the auth provider poked at it, which is how a
|
||||
// hub-invented org that the identity system had never heard of could exist.)
|
||||
//
|
||||
// Writes are optional. A directory that does not own its data returns
|
||||
// ErrManagedElsewhere and the handler answers 409 with ManageURL, so the hub
|
||||
// never needs to know WHY it cannot write — only where the user should go.
|
||||
type Directory interface {
|
||||
// ---- reads (request path) ----
|
||||
Role(orgID, email string) string
|
||||
Get(orgID string) (Org, bool)
|
||||
OrgsFor(email string) []Org
|
||||
ListInvites(orgID string) []OrgInvite
|
||||
ValidInvite(token string) bool
|
||||
// ManageURL is where this org is administered: a path within this hub
|
||||
// when it owns its orgs, an external page when it does not. The client
|
||||
// follows it and never has to know which kind of hub it is talking to.
|
||||
ManageURL(orgID string) string
|
||||
|
||||
// ---- writes (ErrManagedElsewhere when the directory is read-only) ----
|
||||
Create(name, ownerEmail string) (Org, error)
|
||||
Rename(orgID, name string) error
|
||||
AddMember(orgID, email, role string) error
|
||||
SetRole(orgID, email, role string) error
|
||||
RemoveMember(orgID, email string) error
|
||||
CreateInvite(orgID, creator string, ttl time.Duration) (OrgInvite, error)
|
||||
RevokeInvite(token string) bool
|
||||
Redeem(token string) (OrgInvite, bool)
|
||||
RecordInviteUse(token string)
|
||||
}
|
||||
|
||||
// ErrManagedElsewhere is returned by a directory that does not own its
|
||||
// organizations. Handlers turn it into 409 plus the org's ManageURL — the
|
||||
// request was well-formed, it is the state of the world that makes it wrong.
|
||||
var ErrManagedElsewhere = errors.New("this organization is managed outside this hub")
|
||||
|
||||
// LocalDirectory is the built-in directory: organizations owned by this hub,
|
||||
// stored in its own metadata store. This is what every self-hosted install
|
||||
// runs, and its behavior is exactly OrgDB's — the type exists to add the one
|
||||
// thing an org store has no opinion about, which is where to send a browser
|
||||
// to administer an org.
|
||||
type LocalDirectory struct{ *OrgDB }
|
||||
|
||||
// ManageURL is the hub's own org page (a route in the frontend).
|
||||
func (LocalDirectory) ManageURL(orgID string) string { return "/orgs/" + orgID }
|
||||
@@ -0,0 +1,146 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Every Directory implementation has to behave the same way from the hub's
|
||||
// side, whether it owns its organizations or mirrors someone else's. This is
|
||||
// the contract, run against each implementation — the same shape as
|
||||
// db_conformance_test does for MetaStore backends.
|
||||
//
|
||||
// A read-only directory (one whose orgs live in an external identity system)
|
||||
// is a first-class case: it answers reads normally and refuses writes with
|
||||
// ErrManagedElsewhere, which is what lets the hub answer 409 without knowing
|
||||
// anything about the system on the other side.
|
||||
|
||||
// readOnlyDir wraps a directory and refuses every write, the way an
|
||||
// implementation backed by an external identity system does.
|
||||
type readOnlyDir struct{ Directory }
|
||||
|
||||
func (readOnlyDir) Create(string, string) (Org, error) { return Org{}, ErrManagedElsewhere }
|
||||
func (readOnlyDir) Rename(string, string) error { return ErrManagedElsewhere }
|
||||
func (readOnlyDir) AddMember(string, string, string) error { return ErrManagedElsewhere }
|
||||
func (readOnlyDir) SetRole(string, string, string) error { return ErrManagedElsewhere }
|
||||
func (readOnlyDir) RemoveMember(string, string) error { return ErrManagedElsewhere }
|
||||
func (readOnlyDir) CreateInvite(string, string, time.Duration) (OrgInvite, error) {
|
||||
return OrgInvite{}, ErrManagedElsewhere
|
||||
}
|
||||
func (readOnlyDir) ManageURL(orgID string) string { return "https://elsewhere.example/" + orgID }
|
||||
|
||||
func TestDirectoryConformance(t *testing.T) {
|
||||
t.Run("LocalDirectory", func(t *testing.T) {
|
||||
db, err := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dirReads(t, LocalDirectory{OrgDB: db}, true)
|
||||
})
|
||||
|
||||
t.Run("read-only directory", func(t *testing.T) {
|
||||
db, err := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seed through the underlying store: an external directory's contents
|
||||
// arrive by mirroring, not through the hub's write path.
|
||||
o, err := db.Create("acme", "alice@x.io")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := readOnlyDir{Directory: LocalDirectory{OrgDB: db}}
|
||||
dirReads(t, dir, false)
|
||||
|
||||
// Reads still work on the seeded org...
|
||||
if got := dir.Role(o.ID, "alice@x.io"); got != RoleOwner {
|
||||
t.Errorf("Role = %q, want owner", got)
|
||||
}
|
||||
// ...and every write is refused the same recognizable way.
|
||||
for name, err := range map[string]error{
|
||||
"Rename": dir.Rename(o.ID, "nope"),
|
||||
"AddMember": dir.AddMember(o.ID, "bob@x.io", RoleMember),
|
||||
"SetRole": dir.SetRole(o.ID, "alice@x.io", RoleMember),
|
||||
"RemoveMember": dir.RemoveMember(o.ID, "alice@x.io"),
|
||||
} {
|
||||
if !errors.Is(err, ErrManagedElsewhere) {
|
||||
t.Errorf("%s err = %v, want ErrManagedElsewhere", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := dir.Create("other", "alice@x.io"); !errors.Is(err, ErrManagedElsewhere) {
|
||||
t.Errorf("Create err = %v, want ErrManagedElsewhere", err)
|
||||
}
|
||||
if _, err := dir.CreateInvite(o.ID, "alice@x.io", 0); !errors.Is(err, ErrManagedElsewhere) {
|
||||
t.Errorf("CreateInvite err = %v, want ErrManagedElsewhere", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// dirReads exercises the read surface every implementation must answer, and
|
||||
// the writes only an owning directory supports.
|
||||
func dirReads(t *testing.T, dir Directory, writable bool) {
|
||||
t.Helper()
|
||||
|
||||
if writable {
|
||||
o, err := dir.Create("acme", "alice@x.io")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok := dir.Get(o.ID); !ok || got.Name != "acme" {
|
||||
t.Fatalf("Get = %+v ok=%v", got, ok)
|
||||
}
|
||||
if got := dir.Role(o.ID, "ALICE@x.io"); got != RoleOwner {
|
||||
t.Errorf("Role is not email-normalized: %q", got)
|
||||
}
|
||||
if err := dir.AddMember(o.ID, "bob@x.io", RoleMember); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The invariants live in the implementation, not in the caller.
|
||||
if err := dir.RemoveMember(o.ID, "alice@x.io"); err == nil {
|
||||
t.Error("removing the last owner must be refused")
|
||||
}
|
||||
if err := dir.SetRole(o.ID, "alice@x.io", RoleMember); err == nil {
|
||||
t.Error("demoting the last owner must be refused")
|
||||
}
|
||||
inv, err := dir.CreateInvite(o.ID, "alice@x.io", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !dir.ValidInvite(inv.Token) {
|
||||
t.Error("fresh invite is not valid")
|
||||
}
|
||||
if got := dir.ListInvites(o.ID); len(got) != 1 {
|
||||
t.Errorf("ListInvites = %d, want 1", len(got))
|
||||
}
|
||||
if _, ok := dir.Redeem(inv.Token); !ok {
|
||||
t.Error("Redeem failed")
|
||||
}
|
||||
dir.RecordInviteUse(inv.Token)
|
||||
if !dir.RevokeInvite(inv.Token) {
|
||||
t.Error("RevokeInvite failed")
|
||||
}
|
||||
if dir.ValidInvite(inv.Token) {
|
||||
t.Error("revoked invite is still valid")
|
||||
}
|
||||
if len(dir.OrgsFor("bob@x.io")) != 1 {
|
||||
t.Error("OrgsFor missed the org bob belongs to")
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown ids are answered, never panicked on — Role in particular runs on
|
||||
// every project request including device sync.
|
||||
if got := dir.Role("o-nope", "nobody@x.io"); got != "" {
|
||||
t.Errorf("Role of a non-member = %q, want \"\"", got)
|
||||
}
|
||||
if _, ok := dir.Get("o-nope"); ok {
|
||||
t.Error("Get of an unknown org reported ok")
|
||||
}
|
||||
if dir.ValidInvite("not-a-token") {
|
||||
t.Error("bogus invite reported valid")
|
||||
}
|
||||
if dir.ManageURL("o-1234") == "" {
|
||||
t.Error("ManageURL must always give the client somewhere to go")
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func TestE2EServe(t *testing.T) {
|
||||
if err := db.SetOrg(p.ID, org.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.Orgs = orgs
|
||||
srv.Dir = LocalDirectory{OrgDB: orgs}
|
||||
auth.InviteValid = orgs.ValidInvite
|
||||
|
||||
shares, err := OpenShareDB(filepath.Join(state, "shares.json"))
|
||||
|
||||
@@ -16,7 +16,9 @@ test("org admin: members with roles, self marked, rename round-trip", async ({ p
|
||||
await login(page);
|
||||
await openOrgSettings(page);
|
||||
await expect(page.locator("#org-title")).toHaveText("default");
|
||||
await expect(page.locator("#crumb")).toHaveText("default");
|
||||
// The crumb names the surface; repeating the org name under the <h1> that
|
||||
// already says it told the reader nothing.
|
||||
await expect(page.locator("#crumb")).toHaveText("Organization");
|
||||
await expect(page.locator(".admin-item", { hasText: ADMIN })).toContainText("(you)");
|
||||
const memberRow = page.locator(".admin-item", { hasText: MEMBER });
|
||||
await expect(memberRow.locator("select")).toHaveValue("member");
|
||||
@@ -35,6 +37,22 @@ test("org admin: members with roles, self marked, rename round-trip", async ({ p
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
// The org page owns a URL like every other page: the account menu is a plain
|
||||
// link to it, so a deep link and a reload both land on the same view.
|
||||
test("org admin: is a real route, not panel state", async ({ page }) => {
|
||||
await login(page);
|
||||
await openOrgSettings(page);
|
||||
await expect(page).toHaveURL(/\/orgs\/[^/]+$/);
|
||||
const url = page.url();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator("#org-title")).toHaveText("default");
|
||||
|
||||
await page.goto("/");
|
||||
await page.goto(url);
|
||||
await expect(page.locator("#org-title")).toHaveText("default");
|
||||
});
|
||||
|
||||
test("org admin: member role change round-trip", async ({ page }) => {
|
||||
await login(page);
|
||||
await openOrgSettings(page);
|
||||
@@ -96,7 +114,12 @@ test("org admin: project rename and delete", async ({ page }) => {
|
||||
test("member sees the org panel read-only", async ({ page }) => {
|
||||
await login(page, MEMBER);
|
||||
await openOrgSettings(page);
|
||||
await expect(page.locator("#org-title")).toContainText("member");
|
||||
// The role reads as a chip beside the name, not as part of it, and the
|
||||
// short page explains itself rather than looking truncated.
|
||||
// The chip is a sibling of the <h1>, not a child: inside it, the
|
||||
// accessible name of the heading came out as "defaultMember".
|
||||
await expect(page.locator(".role-chip")).toHaveText("Member");
|
||||
await expect(page.locator(".admin-sub")).toContainText("Only owners");
|
||||
await expect(page.locator("#org-rename")).toHaveCount(0);
|
||||
await expect(page.locator(".admin-item select")).toHaveCount(0);
|
||||
await expect(page.locator(".admin-item .ai-tag").first()).toBeVisible(); // role tags
|
||||
@@ -140,7 +163,10 @@ test("members table sorts by email", async ({ page }) => {
|
||||
const emails = page.locator(".admin-table .admin-item .ai-main");
|
||||
await expect(emails.first()).toBeVisible();
|
||||
const before = await emails.allTextContents();
|
||||
await page.click('.admin-table th:has-text("Member")');
|
||||
// Sorting is a button inside the header cell so it is reachable by
|
||||
// keyboard; the header also announces the direction via aria-sort.
|
||||
await page.click('.admin-table th:has-text("Member") .th-sort');
|
||||
await expect(page.locator('.admin-table th:has-text("Member")')).toHaveAttribute("aria-sort", /ascending|descending/);
|
||||
const after = await emails.allTextContents();
|
||||
expect([...before].reverse()).toEqual(after);
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 123 KiB |
@@ -9,10 +9,40 @@ function toLogin(): never {
|
||||
throw new Error("signing in…");
|
||||
}
|
||||
|
||||
// Server messages are written for operators and CLIs — lowercase, unpunctuated,
|
||||
// occasionally naming internals ("forbidden: seat limit reached for plan free").
|
||||
// They surface verbatim in toasts, so the ones we can predict get product copy
|
||||
// and everything else falls back to the server's own words, which is still
|
||||
// better than a generic apology when the cause is specific.
|
||||
function errorFor(status: number, body: string): string {
|
||||
const raw = body.trim();
|
||||
switch (status) {
|
||||
case 403:
|
||||
if (raw.includes("seat")) return "This plan is out of seats. Upgrade to add more people.";
|
||||
if (raw.includes("owner")) return "Only owners can do that.";
|
||||
return "You don't have access to that.";
|
||||
case 409:
|
||||
// The 409 body carries the URL that owns the thing, which is the whole
|
||||
// point of the message — keep it, but capitalize like the rest.
|
||||
return raw ? raw[0].toUpperCase() + raw.slice(1) : "That is managed outside this hub.";
|
||||
case 404:
|
||||
return "That is gone — it may have been removed already.";
|
||||
case 429:
|
||||
return "Too many requests. Give it a moment.";
|
||||
default:
|
||||
if (status >= 500) return "The server had a problem. Try again.";
|
||||
return raw ? raw[0].toUpperCase() + raw.slice(1) : "Something went wrong.";
|
||||
}
|
||||
}
|
||||
|
||||
async function fail(r: Response): Promise<never> {
|
||||
throw new Error(errorFor(r.status, await r.text()));
|
||||
}
|
||||
|
||||
export async function getJSON<T>(url: string): Promise<T> {
|
||||
const r = await fetch(url);
|
||||
if (r.status === 401) toLogin();
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
if (!r.ok) await fail(r);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
@@ -24,7 +54,7 @@ export async function api<T = unknown>(method: string, url: string, body?: unkno
|
||||
opt.body = JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch(url, opt);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
if (!r.ok) await fail(r);
|
||||
return r.status === 204 ? ({} as T) : r.json();
|
||||
}
|
||||
|
||||
@@ -35,6 +65,6 @@ export async function postJSON<T>(url: string, body?: unknown): Promise<T> {
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
if (r.status === 401) toLogin();
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
if (!r.ok) await fail(r);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ export interface Org {
|
||||
role: string; // the signed-in account's role in this org
|
||||
members: OrgMember[];
|
||||
created?: string;
|
||||
// Where this org is administered: the hub's own page on a self-hosted
|
||||
// install, an external directory's page on a managed one. Follow it; do
|
||||
// not branch on it.
|
||||
manage_url: string;
|
||||
}
|
||||
|
||||
export interface OrgList {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { postJSON } from "../api/http";
|
||||
import type { InviteAccepted, Project, ProjectCreated, ServerConfig } from "../api/types";
|
||||
import { useOrgs, usePending, useProjects, useHubRefresh } from "../hooks/useHub";
|
||||
import { parseRoute, urlForView } from "../router";
|
||||
import { navigate, Redirect, useLocationPath } from "../nav";
|
||||
import { linkProps, navigate, Redirect, useLocationPath } from "../nav";
|
||||
import { AppShell, Page, Topbar, VaultHeader, closeSidebarOnMobile } from "../components/shell";
|
||||
import { OrgAdmin } from "../components/OrgAdmin";
|
||||
import { HubSettings } from "../components/HubSettings";
|
||||
@@ -21,9 +21,10 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
// Org just joined via an invite this page-load: prefer its projects over
|
||||
// whatever happens to be first in the list.
|
||||
const [joinedOrgId, setJoinedOrgId] = useState<string | null>(null);
|
||||
// Admin panels replace the content pane without touching the URL (they
|
||||
// were never routes in the classic app); any navigation closes them.
|
||||
const [panel, setPanel] = useState<null | { kind: "hub" } | { kind: "org"; orgId: string }>(null);
|
||||
// The hub-admin panel still replaces the content pane without touching the
|
||||
// URL (the last of the classic app's URL-less surfaces); any navigation
|
||||
// closes it. Org administration is a real route — see /orgs/<id> below.
|
||||
const [panel, setPanel] = useState<null | { kind: "hub" }>(null);
|
||||
useEffect(() => setPanel(null), [pathname]);
|
||||
|
||||
const joinToken = useMemo(() => {
|
||||
@@ -81,6 +82,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
<AccountBar
|
||||
me={config.me}
|
||||
org={org}
|
||||
orgActive={!!route.org}
|
||||
admin={
|
||||
isAdmin
|
||||
? {
|
||||
@@ -92,10 +94,6 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onOrgSettings={(o) => {
|
||||
setPanel({ kind: "org", orgId: o.id });
|
||||
closeSidebarOnMobile();
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
@@ -140,23 +138,38 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
);
|
||||
}
|
||||
|
||||
const panelOrg = panel?.kind === "org" ? orgs.find((o) => o.id === panel.orgId) : null;
|
||||
const activePanel =
|
||||
panel?.kind === "hub"
|
||||
? { crumb: "Signup & access", body: <HubSettings /> }
|
||||
: panelOrg
|
||||
? {
|
||||
crumb: panelOrg.name,
|
||||
body: (
|
||||
<OrgAdmin
|
||||
org={panelOrg}
|
||||
projects={projects}
|
||||
myEmail={config.me?.email || ""}
|
||||
onProjectsChanged={refresh}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: null;
|
||||
const activePanel = panel?.kind === "hub" ? { crumb: "Signup & access", body: <HubSettings /> } : null;
|
||||
|
||||
const routeOrg = route.org ? orgs.find((o) => o.id === route.org) : null;
|
||||
// A stale link, a revoked membership, or a typo: say so. Rendering the
|
||||
// project view at /orgs/<id> told the user nothing and survived a reload.
|
||||
const orgMissing = route.org && !routeOrg;
|
||||
const orgPage = orgMissing
|
||||
? {
|
||||
crumb: "Organization",
|
||||
body: (
|
||||
<div className="empty">
|
||||
<h3>Organization not found</h3>
|
||||
<p>This organization doesn't exist, or you're no longer a member.</p>
|
||||
<p>
|
||||
<a {...linkProps("/" + current.id)}>Back to {current.name}</a>
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
: routeOrg
|
||||
? {
|
||||
crumb: "Organization",
|
||||
body: (
|
||||
<OrgAdmin
|
||||
org={routeOrg}
|
||||
projects={projects}
|
||||
myEmail={config.me?.email || ""}
|
||||
onProjectsChanged={refresh}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
const routePage =
|
||||
route.view === "settings"
|
||||
@@ -172,8 +185,10 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
: null;
|
||||
|
||||
// Landing ("/") and unknown project ids both resolve to a real project
|
||||
// URL; replace so back/forward never bounces through the redirect.
|
||||
if (route.project !== current.id) {
|
||||
// URL; replace so back/forward never bounces through the redirect. The
|
||||
// org route is not project-scoped, so it is exempt — it borrows whichever
|
||||
// project the sidebar is showing.
|
||||
if (!route.org && route.project !== current.id) {
|
||||
return <Redirect to={"/" + current.id} />;
|
||||
}
|
||||
|
||||
@@ -235,7 +250,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
),
|
||||
orgBar: accountBar,
|
||||
}}
|
||||
panel={activePanel || routePage}
|
||||
panel={activePanel || orgPage || routePage}
|
||||
onClosePanel={() => setPanel(null)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import type { Org } from "../api/types";
|
||||
import { linkProps } from "../nav";
|
||||
import { Icon } from "./shell";
|
||||
import { projColor } from "./ProjectNav";
|
||||
import {
|
||||
@@ -13,23 +15,35 @@ import {
|
||||
// opens a menu with the workspace (org) and account actions — settings,
|
||||
// hub administration for admins, and sign-out. Radix owns open/dismiss
|
||||
// behavior (Escape, outside click, focus).
|
||||
//
|
||||
// The org entry is a plain link to org.manage_url: this hub's own org page
|
||||
// when it owns its orgs, the identity provider's page when it does not. The
|
||||
// server decides; nothing here branches on the answer.
|
||||
export function AccountBar({
|
||||
me,
|
||||
org,
|
||||
admin,
|
||||
onOrgSettings,
|
||||
orgActive,
|
||||
}: {
|
||||
me: { email: string; name: string };
|
||||
org: Org | null;
|
||||
admin?: { pending: number; onClick: () => void }; // hub admins only
|
||||
onOrgSettings: (org: Org) => void;
|
||||
orgActive?: boolean; // the org page is the open surface
|
||||
}) {
|
||||
const display = me.name || me.email;
|
||||
// The menu's open state is ours because the org entry is a link: linkProps
|
||||
// calls preventDefault to route internally, and Radix composes its own
|
||||
// select handler with checkForDefaultPrevented, so that handler never runs
|
||||
// and the menu stays open on top of the page it just opened. Closing it
|
||||
// here works for both destinations without giving up a real <a>
|
||||
// (middle-click, copy link address).
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const orgLink = org ? linkProps(org.manage_url) : null;
|
||||
return (
|
||||
<footer id="accountbar">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button id="account-btn" aria-label="Account menu">
|
||||
<button id="account-btn" className={orgActive ? "active" : undefined} aria-label="Account menu">
|
||||
<span className="avatar" style={{ background: projColor(me.email) }} aria-hidden="true">
|
||||
{(display.trim()[0] || "?").toUpperCase()}
|
||||
</span>
|
||||
@@ -44,11 +58,27 @@ export function AccountBar({
|
||||
{org && (
|
||||
<>
|
||||
<DropdownMenuLabel className="menu-sec">Organization</DropdownMenuLabel>
|
||||
<DropdownMenuItem id="menu-org-settings" onSelect={() => onOrgSettings(org)}>
|
||||
<Icon name="gear" />
|
||||
<span>
|
||||
<b>{org.name}</b> Settings
|
||||
</span>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
id="menu-org-settings"
|
||||
aria-current={orgActive ? "page" : undefined}
|
||||
{...orgLink}
|
||||
onClick={(e) => {
|
||||
orgLink?.onClick?.(e);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="gear" />
|
||||
<span>
|
||||
<b>{org.name}</b> Settings
|
||||
</span>
|
||||
{!org.manage_url.startsWith("/") && (
|
||||
<>
|
||||
<span className="ext" aria-hidden="true">↗</span>
|
||||
<span className="sr-only"> (opens in a new tab)</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -83,7 +83,12 @@ export function HubSettings() {
|
||||
inputProps={form.register("require_approval")}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" type="submit" style={{ marginTop: 14 }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={{ marginTop: 14 }}
|
||||
disabled={!form.formState.isDirty}
|
||||
>
|
||||
Save policy
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -29,6 +29,30 @@ const renameSchema = z.object({
|
||||
});
|
||||
type RenameForm = z.infer<typeof renameSchema>;
|
||||
|
||||
// SortableHead is both tables' header cell: a real button inside the th so
|
||||
// sorting is reachable by keyboard, with the direction announced rather than
|
||||
// carried by a glyph alone. Two tables ten centimetres apart had opposite
|
||||
// accessibility contracts before this existed.
|
||||
function SortableHead({ header }: { header: any }) {
|
||||
const sorted = header.column.getIsSorted();
|
||||
if (!header.column.getCanSort()) {
|
||||
// The actions column has no header text and nothing to sort; a button
|
||||
// here is a dead tab stop with an empty accessible name.
|
||||
return <TableHead>{flexRender(header.column.columnDef.header, header.getContext())}</TableHead>;
|
||||
}
|
||||
return (
|
||||
<TableHead
|
||||
data-sort={sorted || undefined}
|
||||
aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "none"}
|
||||
>
|
||||
<button type="button" className="th-sort" onClick={header.column.getToggleSortingHandler()}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{sorted === "asc" ? " ↑" : sorted === "desc" ? " ↓" : ""}
|
||||
</button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
type Member = Org["members"][number];
|
||||
|
||||
export function OrgAdmin({
|
||||
@@ -71,7 +95,13 @@ export function OrgAdmin({
|
||||
|
||||
return (
|
||||
<div className="admin">
|
||||
<h1 id="org-title">{org.name + (owner ? "" : " · member")}</h1>
|
||||
<h1 id="org-title">{org.name}</h1>
|
||||
{!owner && <p className="role-chip-row"><span className="ai-tag role-chip">Member</span></p>}
|
||||
{!owner && (
|
||||
<p className="admin-sub">
|
||||
Only owners can rename this organization, manage members, or issue invite links.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{owner && (
|
||||
<form
|
||||
@@ -86,12 +116,28 @@ export function OrgAdmin({
|
||||
}
|
||||
})}
|
||||
>
|
||||
<input id="org-rename" type="text" {...renameForm.register("name")} />
|
||||
<Button variant="primary" id="org-rename-btn" type="submit">
|
||||
<label className="admin-lbl" htmlFor="org-rename">
|
||||
Organization name
|
||||
</label>
|
||||
<input
|
||||
id="org-rename"
|
||||
type="text"
|
||||
aria-invalid={!!renameForm.formState.errors.name}
|
||||
aria-describedby={renameForm.formState.errors.name ? "org-rename-err" : undefined}
|
||||
{...renameForm.register("name")}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
id="org-rename-btn"
|
||||
type="submit"
|
||||
disabled={!renameForm.formState.isDirty}
|
||||
>
|
||||
Rename org
|
||||
</Button>
|
||||
{renameForm.formState.errors.name && (
|
||||
<span className="field-err">{renameForm.formState.errors.name.message}</span>
|
||||
<span id="org-rename-err" role="alert" className="field-err">
|
||||
{renameForm.formState.errors.name.message}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
@@ -99,6 +145,20 @@ export function OrgAdmin({
|
||||
<h3>Members</h3>
|
||||
<MembersTable org={org} owner={owner} myEmail={myEmail} onChanged={refreshOrgs} />
|
||||
|
||||
{!owner && (
|
||||
<>
|
||||
<h3>Projects</h3>
|
||||
<div className="admin-list">
|
||||
{orgProjects.length === 0 && <div className="admin-empty">No projects yet.</div>}
|
||||
{orgProjects.map((p) => (
|
||||
<div className="admin-item" key={p.id}>
|
||||
<span className="ai-main" title={p.name}>{p.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{owner && (
|
||||
<>
|
||||
<h3>Projects</h3>
|
||||
@@ -106,12 +166,13 @@ export function OrgAdmin({
|
||||
{orgProjects.length === 0 && <div className="admin-empty">No projects yet.</div>}
|
||||
{orgProjects.map((p) => (
|
||||
<div className="admin-item" key={p.id}>
|
||||
<span className="ai-main">{p.name}</span>
|
||||
<span className="ai-main" title={p.name}>{p.name}</span>
|
||||
<Button
|
||||
variant="subtle"
|
||||
aria-label={`Rename ${p.name}`}
|
||||
onClick={async () => {
|
||||
const name = await modalPrompt("Rename project", "New name", p.name, "Rename");
|
||||
if (!name || name === p.name) return;
|
||||
if (name === null || name.trim() === p.name) return;
|
||||
try {
|
||||
await api("PATCH", "/api/projects/" + p.id, { name });
|
||||
toast("Renamed.");
|
||||
@@ -125,6 +186,7 @@ export function OrgAdmin({
|
||||
</Button>
|
||||
<button
|
||||
className="ai-del"
|
||||
aria-label={`Delete ${p.name}`}
|
||||
onClick={async () => {
|
||||
if (
|
||||
!(await modalConfirm(
|
||||
@@ -174,16 +236,17 @@ export function OrgAdmin({
|
||||
)}
|
||||
{(invites || []).map((inv) => (
|
||||
<div className="admin-item" key={inv.token}>
|
||||
<span
|
||||
className="ai-main mono"
|
||||
style={{ cursor: "pointer" }}
|
||||
title="Copy"
|
||||
<button
|
||||
type="button"
|
||||
className="ai-main mono ai-copy"
|
||||
aria-label={`Copy invite link ${inv.url}`}
|
||||
title={inv.url}
|
||||
onClick={() =>
|
||||
copyText(inv.url).then((ok) => toast(ok ? "Copied." : "Select and copy the link."))
|
||||
}
|
||||
>
|
||||
{inv.url}
|
||||
</span>
|
||||
</button>
|
||||
<span className="ai-tag">
|
||||
{(inv.creator ? "by " + inv.creator + " · " : "") +
|
||||
(inv.uses ? inv.uses + " joined · " : "unused · ") +
|
||||
@@ -192,11 +255,12 @@ export function OrgAdmin({
|
||||
</span>
|
||||
<button
|
||||
className="ai-del"
|
||||
aria-label={`Revoke invite ${inv.token.slice(0, 8)}`}
|
||||
onClick={async () => {
|
||||
if (
|
||||
!(await modalConfirm(
|
||||
"Revoke invite",
|
||||
"Revoke this invite link? Anyone still holding it won't be able to join.",
|
||||
`Revoke the link starting ${inv.token.slice(0, 8)}…? Anyone still holding it won't be able to join.`,
|
||||
"Revoke",
|
||||
true,
|
||||
))
|
||||
@@ -248,7 +312,11 @@ function MembersTable({
|
||||
header: "Member",
|
||||
cell: (c) => {
|
||||
const isSelf = !!myEmail && c.getValue().toLowerCase() === myEmail.toLowerCase();
|
||||
return <span className="ai-main">{c.getValue() + (isSelf ? " (you)" : "")}</span>;
|
||||
return (
|
||||
<span className="ai-main" title={c.getValue()}>
|
||||
{c.getValue() + (isSelf ? " (you)" : "")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
}),
|
||||
col.accessor("role", {
|
||||
@@ -257,10 +325,11 @@ function MembersTable({
|
||||
cell: (c) => {
|
||||
const m = c.row.original;
|
||||
const isSelf = !!myEmail && m.email.toLowerCase() === myEmail.toLowerCase();
|
||||
if (!owner || isSelf) return <span className="ai-tag">{m.role}</span>;
|
||||
if (!owner || isSelf) return <span className="ai-tag role-static">{m.role}</span>;
|
||||
return (
|
||||
<>
|
||||
<span className="role-cell">
|
||||
<select
|
||||
aria-label={`Role for ${m.email}`}
|
||||
value={m.role}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
@@ -279,6 +348,7 @@ function MembersTable({
|
||||
</select>
|
||||
<button
|
||||
className="ai-del"
|
||||
aria-label={`Remove ${m.email}`}
|
||||
onClick={async () => {
|
||||
if (
|
||||
!(await modalConfirm("Remove member", `Remove ${m.email} from ${org.name}?`, "Remove", true))
|
||||
@@ -295,7 +365,7 @@ function MembersTable({
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
}),
|
||||
@@ -319,14 +389,7 @@ function MembersTable({
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<TableRow key={hg.id}>
|
||||
{hg.headers.map((h) => (
|
||||
<TableHead
|
||||
key={h.id}
|
||||
onClick={h.column.getToggleSortingHandler()}
|
||||
data-sort={h.column.getIsSorted() || undefined}
|
||||
>
|
||||
{flexRender(h.column.columnDef.header, h.getContext())}
|
||||
{h.column.getIsSorted() === "asc" ? " ↑" : h.column.getIsSorted() === "desc" ? " ↓" : ""}
|
||||
</TableHead>
|
||||
<SortableHead key={h.id} header={h} />
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -361,14 +424,15 @@ function SharesTable({
|
||||
col.accessor("path", {
|
||||
header: "Path",
|
||||
cell: (c) => (
|
||||
<span
|
||||
<a
|
||||
className="ai-main mono"
|
||||
style={{ cursor: "pointer" }}
|
||||
title={c.row.original.url}
|
||||
onClick={() => window.open(c.row.original.url, "_blank")}
|
||||
href={c.row.original.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={c.getValue()}
|
||||
>
|
||||
{c.getValue()}
|
||||
</span>
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
col.accessor((s) => s.project_name || "", {
|
||||
@@ -388,6 +452,7 @@ function SharesTable({
|
||||
cell: (c) => (
|
||||
<button
|
||||
className="ai-del"
|
||||
aria-label={`Revoke the share of ${c.row.original.path}`}
|
||||
onClick={async () => {
|
||||
const sh = c.row.original;
|
||||
if (
|
||||
@@ -439,9 +504,7 @@ function SharesTable({
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<TableRow key={hg.id}>
|
||||
{hg.headers.map((h) => (
|
||||
<TableHead key={h.id} onClick={h.column.getToggleSortingHandler()}>
|
||||
{flexRender(h.column.columnDef.header, h.getContext())}
|
||||
</TableHead>
|
||||
<SortableHead key={h.id} header={h} />
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function ProjectNav({
|
||||
|
||||
const create = async () => {
|
||||
const name = await modalPrompt("New project", "Project name", "", "Create");
|
||||
if (!name) return;
|
||||
if (name === null) return;
|
||||
try {
|
||||
const out = await postJSON<ProjectCreated>("/api/projects", { name });
|
||||
await refresh();
|
||||
@@ -59,7 +59,7 @@ export function ProjectNav({
|
||||
<nav id="projects" aria-label="Projects">
|
||||
<div className="nav-head">
|
||||
<span>Projects</span>
|
||||
<button className="nav-add" title="New project" onClick={create}>
|
||||
<button className="nav-add" title="New project" aria-label="New project" onClick={create}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
@@ -73,7 +73,12 @@ export function ProjectNav({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="project-select" aria-label="Switch project" className="proj-trigger">
|
||||
<SelectTrigger
|
||||
id="project-select"
|
||||
aria-label={`Switch project — current: ${projects.find((p) => p.id === currentId)?.name ?? "none"}`}
|
||||
title={projects.find((p) => p.id === currentId)?.name}
|
||||
className="proj-trigger"
|
||||
>
|
||||
{currentId && (
|
||||
<span
|
||||
className="proj-mark"
|
||||
|
||||
@@ -37,10 +37,65 @@ import {
|
||||
// so style.css applies unchanged.
|
||||
|
||||
export function toggleSidebar() {
|
||||
const opening = !document.body.classList.contains("sb-open");
|
||||
document.body.classList.toggle("sb-open");
|
||||
syncSidebarInert();
|
||||
// The drawer is modal for the mouse (scrim, click-to-dismiss); make it modal
|
||||
// for the keyboard too. Without this, Tab walks from the drawer into the
|
||||
// content BEHIND the scrim, where clicks do nothing — and because the menu
|
||||
// button follows the sidebar in DOM order, the nav was only reachable by
|
||||
// tabbing backwards.
|
||||
if (opening) {
|
||||
document.getElementById("sidebar")?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
|
||||
} else {
|
||||
document.getElementById("menu-btn")?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const FOCUSABLE = 'a[href], button:not(:disabled), select, input, [tabindex]:not([tabindex="-1"])';
|
||||
export function closeSidebarOnMobile() {
|
||||
const wasOpen = document.body.classList.contains("sb-open");
|
||||
document.body.classList.remove("sb-open");
|
||||
syncSidebarInert();
|
||||
// Closing returns focus to the control that opened it — otherwise focus
|
||||
// falls to <body> and the next Tab restarts from the top of the document.
|
||||
if (wasOpen && window.innerWidth <= SIDEBAR_BREAKPOINT) {
|
||||
document.getElementById("menu-btn")?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// The sidebar is off-canvas below the breakpoint — translated out of view, but
|
||||
// still in the DOM, so its nine controls stayed in the tab order with no
|
||||
// visible focus indicator: nine dead stops before the first thing on screen
|
||||
// (WCAG 2.4.7). `inert` removes focusability and AT exposure together, which
|
||||
// is exactly the "it isn't there right now" the transform already implies.
|
||||
const SIDEBAR_BREAKPOINT = 900; // must match the off-canvas media query in style.css
|
||||
|
||||
export function syncSidebarInert() {
|
||||
const el = document.getElementById("sidebar");
|
||||
if (!el) return;
|
||||
const open = document.body.classList.contains("sb-open");
|
||||
const hidden = window.innerWidth <= SIDEBAR_BREAKPOINT && !open;
|
||||
if (hidden) el.setAttribute("inert", "");
|
||||
else el.removeAttribute("inert");
|
||||
// The mirror: while the drawer is over the page, the page is not reachable.
|
||||
const main = document.getElementById("main");
|
||||
if (main) {
|
||||
if (open && window.innerWidth <= SIDEBAR_BREAKPOINT) main.setAttribute("inert", "");
|
||||
else main.removeAttribute("inert");
|
||||
}
|
||||
el.setAttribute("aria-modal", String(open && window.innerWidth <= SIDEBAR_BREAKPOINT));
|
||||
// The trigger's state lives in a body class rather than React state, so it
|
||||
// is declared from here — the one place that always runs when it changes.
|
||||
document.getElementById("menu-btn")?.setAttribute("aria-expanded", String(open));
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("resize", syncSidebarInert);
|
||||
// Escape closes the drawer, the way every other overlay in the app does.
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && document.body.classList.contains("sb-open")) closeSidebarOnMobile();
|
||||
});
|
||||
}
|
||||
|
||||
// Icons are lucide (lucide.dev) components behind the historical sprite
|
||||
@@ -132,6 +187,12 @@ export function Page(props: {
|
||||
return <div className={cls}>{props.children}</div>;
|
||||
}
|
||||
|
||||
// Applies the initial inert state as soon as the element exists, so the first
|
||||
// paint at a small width is already correct.
|
||||
function sidebarInert(el: HTMLElement | null) {
|
||||
if (el) syncSidebarInert();
|
||||
}
|
||||
|
||||
export function AppShell(props: {
|
||||
vault: ReactNode;
|
||||
projectsNav?: ReactNode;
|
||||
@@ -145,7 +206,7 @@ export function AppShell(props: {
|
||||
return (
|
||||
<>
|
||||
<div id="sb-backdrop" onClick={closeSidebarOnMobile} />
|
||||
<aside id="sidebar">
|
||||
<aside id="sidebar" ref={sidebarInert}>
|
||||
{props.vault}
|
||||
{props.projectsNav}
|
||||
{props.tree ?? <nav id="tree" aria-label="Files" />}
|
||||
@@ -226,7 +287,15 @@ export function VaultHeader(props: {
|
||||
export function Topbar(props: { crumb?: ReactNode; meta?: ReactNode; actions?: ReactNode }) {
|
||||
return (
|
||||
<header id="topbar">
|
||||
<button id="menu-btn" className="icon-btn" title="Menu" aria-label="Menu" onClick={toggleSidebar}>
|
||||
<button
|
||||
id="menu-btn"
|
||||
className="icon-btn"
|
||||
title="Menu"
|
||||
aria-label="Menu"
|
||||
aria-controls="sidebar"
|
||||
aria-expanded="false"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<span id="crumb">{props.crumb}</span>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[background-color,border-color,color] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useSyncExternalStore } from "react";
|
||||
import { useRef, useState, useSyncExternalStore } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -91,23 +91,46 @@ function PromptBody({ m }: { m: Prompt }) {
|
||||
emit(null);
|
||||
m.resolve(v);
|
||||
};
|
||||
const ok = () => done(input.current!.value.trim() || null);
|
||||
// Return the raw string: null means cancelled, "" means the user submitted
|
||||
// nothing. Collapsing the two here made every caller's blank-input branch
|
||||
// unreachable.
|
||||
const [err, setErr] = useState("");
|
||||
const ok = () => {
|
||||
const v = input.current!.value;
|
||||
if (!v.trim()) {
|
||||
// Keep the dialog open and say so where the user is looking, the way
|
||||
// the org-rename form does — closing and toasting loses their context.
|
||||
setErr("Give it a name.");
|
||||
input.current!.focus();
|
||||
return;
|
||||
}
|
||||
done(v);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<DialogTitle asChild>
|
||||
<h3>{m.title}</h3>
|
||||
</DialogTitle>
|
||||
<label className="modal-label">{m.label}</label>
|
||||
<label className="modal-label" htmlFor="modal-input">{m.label}</label>
|
||||
<input
|
||||
className="modal-input"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
defaultValue={m.value}
|
||||
ref={input}
|
||||
id="modal-input"
|
||||
autoFocus
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
aria-invalid={!!err}
|
||||
aria-describedby={err ? "modal-input-err" : undefined}
|
||||
onChange={() => err && setErr("")}
|
||||
onKeyDown={(e) => e.key === "Enter" && ok()}
|
||||
/>
|
||||
{err && (
|
||||
<span id="modal-input-err" role="alert" className="field-err">
|
||||
{err}
|
||||
</span>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<Button variant="subtle" onClick={() => done(null)}>
|
||||
Cancel
|
||||
@@ -132,10 +155,10 @@ function ConfirmBody({ m }: { m: Confirm }) {
|
||||
</DialogTitle>
|
||||
<p className="modal-msg">{m.message}</p>
|
||||
<div className="modal-actions">
|
||||
<Button variant="subtle" onClick={() => done(false)}>
|
||||
<Button variant="subtle" onClick={() => done(false)} autoFocus={m.danger}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant={m.danger ? "danger" : "primary"} onClick={() => done(true)} autoFocus>
|
||||
<Button variant={m.danger ? "danger" : "primary"} onClick={() => done(true)} autoFocus={!m.danger}>
|
||||
{m.confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { useEffect, useSyncExternalStore, type MouseEvent } from "react";
|
||||
|
||||
// Minimal synchronous history router. React Router v7 wraps navigation in
|
||||
// React.startTransition, which can leave the old view on screen for
|
||||
@@ -46,6 +46,30 @@ export function currentNavType(): NavType {
|
||||
return navType;
|
||||
}
|
||||
|
||||
// The single place that decides whether an href is ours to route or the
|
||||
// browser's to follow. Everything else just renders a link and lets the
|
||||
// server say where it points — no component needs to know which kind of
|
||||
// destination it got.
|
||||
export function linkProps(href: string) {
|
||||
const internal = href.startsWith("/") && !href.startsWith("//");
|
||||
// An external destination leaves the app, so it opens in its own tab and
|
||||
// says so (callers render an ↗ off the same datum).
|
||||
if (!internal) return { href, target: "_blank", rel: "noopener noreferrer" };
|
||||
return {
|
||||
href,
|
||||
onClick: (e: MouseEvent) => {
|
||||
// Leave modified clicks (new tab/window, download) to the browser.
|
||||
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
navigate(href);
|
||||
// Navigating away closes the off-canvas sidebar. This lives here, not
|
||||
// in each link, because every in-app link needs it and the ones that
|
||||
// forget leave the drawer sitting on top of the page it just opened.
|
||||
document.body.classList.remove("sb-open");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Render-time redirect (the declarative <Navigate replace> equivalent).
|
||||
export function Redirect({ to }: { to: string }) {
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// volume mode: /<path>
|
||||
// hub mode: /<project-id>/<path>
|
||||
// invite: /join/<token>
|
||||
// org admin: /orgs/<org-id>
|
||||
// Each path segment is percent-encoded for odd characters, but the "/"
|
||||
// separators stay literal so the URL reads like a real file path. This is
|
||||
// why routes are parsed by hand instead of with a route-matching library:
|
||||
@@ -28,6 +29,10 @@ export const VIEW_ROUTES = new Set(["insights", "history", "install", "settings"
|
||||
export type ViewName = "insights" | "history" | "install" | "settings";
|
||||
|
||||
export interface Route {
|
||||
// Org administration is not project-scoped, so it is a top-level route
|
||||
// rather than a view under a project. The server hands out this URL (see
|
||||
// manage_url on /api/orgs), which is why it is reserved here.
|
||||
org?: string;
|
||||
project?: string;
|
||||
path: string;
|
||||
view?: ViewName;
|
||||
@@ -37,6 +42,9 @@ export interface Route {
|
||||
export function parseRoute(pathname: string, mode: "volume" | "hub"): Route {
|
||||
const raw = pathname.replace(/^\/+/, "");
|
||||
if (mode !== "hub") return { path: raw ? decodePath(raw) : "" };
|
||||
if (raw === "orgs" || raw.startsWith("orgs/")) {
|
||||
return { org: raw.slice(5).replace(/\/+$/, ""), path: "" };
|
||||
}
|
||||
const slash = raw.indexOf("/");
|
||||
if (slash === -1) return { project: raw, path: "" };
|
||||
const r: Route = { project: raw.slice(0, slash), path: decodePath(raw.slice(slash + 1)) };
|
||||
|
||||
@@ -76,6 +76,17 @@ body {
|
||||
}
|
||||
::selection { background: var(--glow); color: var(--accent-bright); }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 5px; }
|
||||
:focus-visible { outline-color: var(--accent); }
|
||||
.admin input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.admin input[aria-invalid="true"]:focus-visible { outline-color: var(--del); }
|
||||
[role="dialog"] input[aria-invalid="true"] { border-color: var(--del); }
|
||||
[role="dialog"] input[aria-invalid="true"]:focus-visible { outline-color: var(--del); }
|
||||
button:disabled, .btn:disabled { cursor: default; }
|
||||
input[type="checkbox"] { accent-color: var(--accent); width: 20px; height: 20px; color-scheme: dark; }
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
|
||||
}
|
||||
.sprite { position: absolute; }
|
||||
.ico {
|
||||
width: 16px; height: 16px; flex: none;
|
||||
@@ -176,19 +187,31 @@ button, input, a.btn { font-family: inherit; }
|
||||
.modal, #palette { translate: none; }
|
||||
|
||||
/* react-table admin tables ride the .admin-item/.ai-* vocabulary */
|
||||
.admin-card-table { padding: 0; overflow: hidden; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; }
|
||||
.admin-card-table { padding: 0; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.admin-table td .ai-main, .admin-table td a.ai-main, .admin-table td .ai-copy,
|
||||
.admin-table td .ai-tag {
|
||||
display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
/* table-layout: fixed takes its tracks from the first row, so the actions
|
||||
column needs a real width — otherwise `width: 100%` on the first cell
|
||||
starves it to nothing and the content of column two spills across it. */
|
||||
/* table-layout: fixed takes its tracks from the FIRST ROW, which is the
|
||||
header — sizing td alone did nothing. The last column holds the row's
|
||||
controls (a role select plus a destructive button), so it gets a real
|
||||
track and the text column takes the rest. */
|
||||
.admin-table th:last-child, .admin-table td:last-child { width: 186px; text-align: right; }
|
||||
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.admin-table th {
|
||||
text-align: left; font-size: 11px; font-weight: 600; letter-spacing: .05em;
|
||||
text-transform: uppercase; color: var(--text-faint); padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border); cursor: pointer; user-select: none;
|
||||
text-transform: uppercase; color: var(--text-faint); padding: 0;
|
||||
border-bottom: 1px solid var(--border); user-select: none;
|
||||
}
|
||||
.admin-table th:hover { color: var(--text-dim); }
|
||||
.admin-table td { padding: 0; border-bottom: 1px solid var(--border); }
|
||||
.admin-table td { padding: 0; border-bottom: 1px solid var(--border); overflow: hidden; text-overflow: ellipsis; }
|
||||
.admin-table tr.admin-item { display: table-row; }
|
||||
.admin-table tr.admin-item td { padding: 8px 10px; }
|
||||
.admin-table tr.admin-item td:first-child { width: 100%; }
|
||||
|
||||
|
||||
/* ---- project menu ---- */
|
||||
.nav-menu { list-style: none; margin: 6px 0 0; padding: 0; }
|
||||
@@ -253,8 +276,8 @@ button, input, a.btn { font-family: inherit; }
|
||||
#account-menu [role="menuitem"]:hover { background: var(--hover); color: var(--text); }
|
||||
#account-menu [role="menuitem"] b { font-weight: 600; }
|
||||
#account-menu [role="menuitem"] .ico { width: 15px; height: 15px; }
|
||||
#account-menu #signout { color: var(--danger, #e5534b); }
|
||||
#account-menu #signout:hover { color: var(--danger, #e5534b); background: var(--hover); }
|
||||
#account-menu #signout { color: var(--del); }
|
||||
#account-menu #signout:hover { color: var(--del); background: var(--hover); }
|
||||
|
||||
/* ---- main pane ---- */
|
||||
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
@@ -333,31 +356,71 @@ button, input, a.btn { font-family: inherit; }
|
||||
/* width comes from .page (app) — see #content */
|
||||
.admin h1 { font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 6px; color: #f4f6f9; }
|
||||
.admin h3 { font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-faint); font-weight: 600; margin: 30px 0 10px; }
|
||||
.admin-sub { color: var(--text-dim); font-size: 13.5px; margin: 0 0 6px; line-height: 1.55; }
|
||||
.admin-lbl { flex: 1 1 100%; margin: 0 0 6px; font-size: 12.5px; font-weight: 600; color: var(--text-dim); }
|
||||
.admin-sub { color: var(--text-dim); font-size: 13.5px; margin: -2px 0 16px; line-height: 1.55; }
|
||||
.admin-h { display: flex; align-items: center; justify-content: space-between; margin: 30px 0 10px; }
|
||||
.admin-h h3 { margin: 0; }
|
||||
.admin-row { display: flex; gap: 9px; margin-bottom: 8px; }
|
||||
.admin-row input { flex: 1; height: 34px; padding: 0 12px; border-radius: var(--r-ctl); border: 1px solid var(--border); background: var(--surface); color: var(--text); font: inherit; font-size: 13px; outline: none; }
|
||||
.admin-row input:focus { border-color: var(--accent); background: var(--hover); }
|
||||
.admin-list { border: 1px solid var(--border); border-radius: var(--r-card); overflow: hidden; background: var(--bg-side); }
|
||||
/* Belt and braces, and it must come after .admin-list: if a column still
|
||||
overflows, scroll it rather than clipping a destructive control out of
|
||||
reach. */
|
||||
.admin-list.admin-card-table { overflow-x: auto; overflow-y: hidden; }
|
||||
.admin-item { display: flex; align-items: center; gap: 11px; padding: 11px 14px; border-bottom: 1px solid var(--border); font-size: 13.5px; }
|
||||
.modal-actions .ai-btn { height: 32px; }
|
||||
.empty a { color: var(--accent); text-decoration: none; display: inline-block; padding: 6px 10px; }
|
||||
.empty a:hover { text-decoration: underline; }
|
||||
.empty h3 { margin: 0 0 8px; font-size: 16px; color: var(--text); }
|
||||
.ai-copy { text-align: left; background: none; border: 0; padding: 6px 0; cursor: pointer; }
|
||||
.ai-copy:hover { color: var(--text); }
|
||||
a.ai-main { color: var(--text-dim); text-decoration: none; padding: 6px 0; }
|
||||
a.ai-main:hover { color: var(--accent); }
|
||||
.th-sort { display: block; width: 100%; text-align: left; background: none; border: 0; padding: 6px 10px; font: inherit; color: inherit; letter-spacing: inherit; text-transform: inherit; cursor: pointer; }
|
||||
.th-sort:hover { color: var(--text-dim); }
|
||||
/* Role controls share one track so the value sits at the same x on every
|
||||
row, whether it is a select or the static tag on your own row. */
|
||||
.proj-trigger > [data-slot="select-value"] {
|
||||
display: block; flex: 1 1 auto; min-width: 0; text-align: left;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.role-cell { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; justify-items: start; }
|
||||
.admin-table td .role-static { text-align: left; }
|
||||
.role-chip-row { margin: -6px 0 12px; }
|
||||
.role-chip { margin-left: 0; padding: 2px 8px; border: 1px solid var(--border-2); border-radius: 99px; vertical-align: middle; }
|
||||
.ext { margin-left: 4px; color: var(--text-faint); font-size: 11px; }
|
||||
.admin-item:last-child { border-bottom: none; }
|
||||
.admin-item:hover { background: rgba(255,255,255,.015); }
|
||||
.admin-item:hover { background: var(--hover); }
|
||||
.field-err { flex: 1 1 100%; margin: 6px 0 0; }
|
||||
.admin-row { flex-wrap: wrap; }
|
||||
.admin-row input[aria-invalid="true"] { border-color: var(--del); }
|
||||
.ai-main { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
||||
.admin-item > .ai-main { flex: 1 1 55%; min-width: 22ch; }
|
||||
.admin-item > .ai-tag { flex: 0 0 auto; min-width: 0; max-width: 45%; }
|
||||
@media (max-width: 1000px) {
|
||||
/* Between the drawer breakpoint and 1000px the meta still clipped; let it
|
||||
drop to its own line rather than lose the expiry year. */
|
||||
.admin-item { flex-wrap: wrap; }
|
||||
.admin-item > .ai-tag { flex: 1 1 100%; max-width: 100%; }
|
||||
}
|
||||
.admin-table td .ai-main { min-width: 0; }
|
||||
.ai-main.mono { font: 12px var(--mono); color: var(--text-dim); cursor: pointer; }
|
||||
.ai-tag { font-size: 11.5px; color: var(--text-faint); flex: none; }
|
||||
.ai-tag { font-size: 11.5px; color: var(--text-faint); flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-item select { height: 28px; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 0 8px; font: inherit; font-size: 12.5px; cursor: pointer; }
|
||||
.admin-item select:hover { border-color: var(--border-2); }
|
||||
.ai-btn, .ai-del { flex: none; height: 27px; padding: 0 11px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface); color: var(--text-dim); font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; }
|
||||
/* One destructive treatment: Delete removes a whole project and was rendering
|
||||
as bare text beside a bordered Rename. */
|
||||
.ai-del { color: var(--del); border-color: rgba(242, 109, 109, .28); }
|
||||
.ai-del:hover { background: rgba(242, 109, 109, .12); border-color: var(--del); color: #ff8b8b; }
|
||||
.ai-btn:hover { background: var(--hover); color: var(--text); border-color: var(--border-2); }
|
||||
.ai-del { color: var(--del); border-color: transparent; background: transparent; }
|
||||
.ai-del:hover { background: rgba(242,109,109,.12); color: #ff8b8b; }
|
||||
.admin-empty { padding: 14px; color: var(--text-faint); font-size: 13px; }
|
||||
.admin-item.toggle { cursor: pointer; align-items: flex-start; }
|
||||
.admin-item.toggle .ai-main { white-space: normal; }
|
||||
.tg-label { font-size: 13.5px; font-weight: 550; color: var(--text); }
|
||||
.tg-desc { font-size: 12px; color: var(--text-faint); margin-top: 3px; line-height: 1.5; }
|
||||
.admin-item.toggle input { width: 16px; height: 16px; margin-top: 2px; accent-color: var(--accent); flex: none; }
|
||||
.admin-item.toggle input { margin-top: 2px; flex: none; }
|
||||
|
||||
/* ---- folder listing ---- */
|
||||
/* .dirlist width comes from .page (app) */
|
||||
@@ -395,6 +458,8 @@ button, input, a.btn { font-family: inherit; }
|
||||
.gd-tabs { display: flex; gap: 2px; margin: 20px 0 16px; border-bottom: 1px solid var(--border); overflow-x: auto; }
|
||||
.gd-tab { font: inherit; font-size: 13px; font-weight: 600; padding: 7px 12px 9px; background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; color: var(--text-faint); cursor: pointer; white-space: nowrap; }
|
||||
.gd-tab:hover { color: var(--text); }
|
||||
#account-btn.active { background: var(--glow); }
|
||||
#account-btn.active .acct b { color: var(--accent-bright); }
|
||||
.gd-tab.active { color: var(--accent-bright); border-bottom-color: var(--accent); }
|
||||
.gd-step { margin: 0 0 18px; }
|
||||
.gd-step-head { display: flex; align-items: center; gap: 10px; margin-bottom: 3px; }
|
||||
@@ -542,7 +607,7 @@ button, input, a.btn { font-family: inherit; }
|
||||
#sidebar { position: fixed; z-index: 60; top: 0; left: 0; height: 100%; transform: translateX(-100%); transition: transform .2s ease; box-shadow: 0 0 40px rgba(0,0,0,.6); }
|
||||
body.sb-open #sidebar { transform: translateX(0); }
|
||||
body.sb-open #sb-backdrop { display: block; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 50; }
|
||||
.icon-btn { display: inline-flex; width: 44px; height: 44px; }
|
||||
.icon-btn, #search-btn { display: inline-flex; width: 44px; height: 44px; }
|
||||
#content { padding: 24px 18px 70px; }
|
||||
#topbar { padding: 0 8px; gap: 4px; }
|
||||
.btn .lbl { display: none; }
|
||||
@@ -570,12 +635,47 @@ button, input, a.btn { font-family: inherit; }
|
||||
.admin-item { flex-wrap: wrap; row-gap: 8px; padding: 12px 14px; }
|
||||
.admin-item select { height: 44px; }
|
||||
.ai-btn, .ai-del { height: auto; min-height: 44px; padding: 0 12px; }
|
||||
/* react-table renders rows as `display: table-row`, which makes every
|
||||
flex rule above inert and lets the last column (Remove) fall outside
|
||||
the card, where `.admin-card-table { overflow: hidden }` clips it —
|
||||
one long email made member removal impossible on every row. Stack the
|
||||
table into rows instead; a nested horizontal scroller on touch would
|
||||
be undiscoverable. */
|
||||
.admin-table thead { display: none; }
|
||||
.admin-table, .admin-table tbody, .admin-table td { display: block; width: auto; }
|
||||
.admin-table tr.admin-item { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.admin-table tr.admin-item td { padding: 0; border-bottom: none; }
|
||||
.admin-table tr.admin-item td:first-child { flex: 1 1 100%; width: auto; }
|
||||
/* Beats `.admin-table th:last-child, td:last-child` (0,0,2,1): stacked rows
|
||||
are flex children, so the desktop track and right-alignment are wrong. */
|
||||
.admin-table tr.admin-item td:last-child { width: auto; text-align: left; }
|
||||
/* Radix menu items miss the 44px floor the rest of the app meets. */
|
||||
[data-slot="dropdown-menu-item"] { min-height: 44px; }
|
||||
/* The rename row put a 34px input beside a 44px button and truncated the
|
||||
value; stack them at full height instead. */
|
||||
/* The projects list was capped at 32% of the drawer and sliced "Settings"
|
||||
in half on every phone; let the tree below absorb the pressure instead. */
|
||||
#projects { flex: 0 1 auto; max-height: none; }
|
||||
.admin-row { flex-wrap: wrap; }
|
||||
.admin-row input { flex: 1 1 100%; min-height: 44px; }
|
||||
.admin-row button { flex: 0 0 auto; align-self: flex-start; min-height: 44px; }
|
||||
/* No hover on touch: a truncated email/URL would be unreadable — wrap
|
||||
it. The name/URL takes the whole row (tag + buttons drop below):
|
||||
without full-row basis, a long flex:none .ai-tag starves the URL down
|
||||
to one character per line. break-word prefers natural break points
|
||||
(@, .) and still splits long unbroken URLs when it must. */
|
||||
.admin-item .ai-main { flex: 1 1 100%; white-space: normal; overflow-wrap: break-word; }
|
||||
.admin-item .ai-main { flex: 1 1 100%; white-space: normal; overflow-wrap: anywhere; }
|
||||
/* Beats the desktop truncation rule (0,2,1) inside the stacked table. */
|
||||
.admin-table td { white-space: normal; }
|
||||
.admin-table td .ai-main, .admin-table td a.ai-main, .admin-table td .ai-copy, .admin-table td .ai-tag {
|
||||
white-space: normal; overflow-wrap: anywhere;
|
||||
}
|
||||
/* Invite meta (uses, expiry) is the only triage signal an owner has; it was
|
||||
truncating to "· unus…" on every phone-width row. */
|
||||
.admin-item .ai-tag { flex: 1 1 100%; max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
|
||||
/* The invite row's primary action was a 28px target. */
|
||||
.ai-copy { min-height: 44px; display: block; padding: 12px 0; white-space: normal; overflow-wrap: anywhere; text-overflow: clip; }
|
||||
a.ai-main { min-height: 44px; display: flex; align-items: center; }
|
||||
/* The code-block Copy button needs a real touch target; give one-line
|
||||
blocks the height to hold it. */
|
||||
.gd-code { min-height: 62px; padding-top: 12px; padding-bottom: 12px; }
|
||||
@@ -638,7 +738,12 @@ button, input, a.btn { font-family: inherit; }
|
||||
.markdown table.frontmatter td { color: var(--text-dim); padding: 6px 12px 6px 0; border-bottom: 1px solid var(--border); }
|
||||
.markdown table.frontmatter tr:last-child th, .markdown table.frontmatter tr:last-child td { border-bottom: none; }
|
||||
.markdown table.frontmatter code { white-space: pre-wrap; font-size: 11px; }
|
||||
.markdown input[type="checkbox"] { accent-color: var(--accent); }
|
||||
.markdown .admin input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.admin input[aria-invalid="true"]:focus-visible { outline-color: var(--del); }
|
||||
[role="dialog"] input[aria-invalid="true"] { border-color: var(--del); }
|
||||
[role="dialog"] input[aria-invalid="true"]:focus-visible { outline-color: var(--del); }
|
||||
button:disabled, .btn:disabled { cursor: default; }
|
||||
input[type="checkbox"] { accent-color: var(--accent); }
|
||||
|
||||
/* rendered HTML files: a sandboxed page in a framed viewport */
|
||||
.htmlview { display: block; width: 100%; height: calc(100vh - 150px); border: 1px solid var(--border); border-radius: var(--r-card); background: #fff; }
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Toaster as SonnerToaster } from "@/components/ui/sonner";
|
||||
// are markup-agnostic.
|
||||
|
||||
export function toast(msg: string, isErr = false) {
|
||||
if (isErr) sonner.error(msg);
|
||||
// Errors stay until dismissed: a failed action the user has to react to
|
||||
// should not vanish while they are reading it.
|
||||
if (isErr) sonner.error(msg, { duration: Infinity, closeButton: true });
|
||||
else sonner(msg);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
--color-destructive: #f26d6d;
|
||||
--color-border: rgba(255, 255, 255, 0.07);
|
||||
--color-input: rgba(255, 255, 255, 0.11);
|
||||
--color-ring: #d3861a;
|
||||
--color-ring: #f5a623; /* full amber: the 50%-alpha dim variant measured 2.46:1 */
|
||||
|
||||
--radius-ctl: 7px;
|
||||
--radius-card: 10px;
|
||||
|
||||
@@ -2,6 +2,7 @@ package webapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -320,7 +321,16 @@ func (db *OrgDB) ValidInvite(token string) bool {
|
||||
// predates organizations keeps working with zero manual steps. All existing
|
||||
// accounts join it — they could all see every project before, so anything
|
||||
// narrower would lock someone out — with the oldest account as owner.
|
||||
func MigrateOrgs(projects *ProjectDB, orgs *OrgDB, accounts []User) error {
|
||||
// orgWriter is the slice of Directory that MigrateOrgs needs: it creates one
|
||||
// org and fills it. Taking the narrow type keeps the sweep usable with a bare
|
||||
// OrgDB (which is what the CLI has at that point) instead of forcing a
|
||||
// LocalDirectory wrapper on a function that has no use for ManageURL.
|
||||
type orgWriter interface {
|
||||
Create(name, ownerEmail string) (Org, error)
|
||||
AddMember(orgID, email, role string) error
|
||||
}
|
||||
|
||||
func MigrateOrgs(projects *ProjectDB, orgs orgWriter, accounts []User) error {
|
||||
var orphans []Project
|
||||
for _, p := range projects.List() {
|
||||
if p.Org == "" {
|
||||
@@ -366,26 +376,26 @@ func (s *Server) orgOf(projectID string) string {
|
||||
// Without an org registry (single-volume mode, tests, pre-org hubs) every
|
||||
// authenticated request passes, preserving the old behavior.
|
||||
func (s *Server) projectAllowed(r *http.Request, projectID string) bool {
|
||||
if s.Orgs == nil || s.Auth == nil {
|
||||
if s.Dir == nil || s.Auth == nil {
|
||||
return true
|
||||
}
|
||||
org := s.orgOf(projectID)
|
||||
if org == "" {
|
||||
return true // org-less project (migration happens at startup)
|
||||
}
|
||||
return s.Orgs.Role(org, s.requestUser(r).Email) != ""
|
||||
return s.Dir.Role(org, s.requestUser(r).Email) != ""
|
||||
}
|
||||
|
||||
// handleOrgList returns the caller's orgs with members (visible to any
|
||||
// member) and the caller's role.
|
||||
func (s *Server) handleOrgList(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Orgs == nil {
|
||||
if s.Dir == nil {
|
||||
writeJSON(w, map[string]any{"orgs": []any{}})
|
||||
return
|
||||
}
|
||||
me := s.requestUser(r)
|
||||
out := []map[string]any{}
|
||||
for _, o := range s.Orgs.OrgsFor(me.Email) {
|
||||
for _, o := range s.Dir.OrgsFor(me.Email) {
|
||||
members := make([]map[string]string, 0, len(o.Members))
|
||||
for email, role := range o.Members {
|
||||
members = append(members, map[string]string{"email": email, "role": role})
|
||||
@@ -394,20 +404,34 @@ func (s *Server) handleOrgList(w http.ResponseWriter, r *http.Request) {
|
||||
out = append(out, map[string]any{
|
||||
"id": o.ID, "name": o.Name, "role": o.Members[normEmail(me.Email)],
|
||||
"members": members, "created": o.Created,
|
||||
"manage_url": s.Dir.ManageURL(o.ID),
|
||||
})
|
||||
}
|
||||
writeJSON(w, map[string]any{"orgs": out})
|
||||
}
|
||||
|
||||
// writeDirErr answers a failed directory write. A directory that does not own
|
||||
// its organizations says so with ErrManagedElsewhere, and the answer is 409
|
||||
// plus the page that does own them — the request was well-formed, it is the
|
||||
// state of the world that makes it wrong. The hub never learns WHY the write
|
||||
// was refused, only where to send the user.
|
||||
func (s *Server) writeDirErr(w http.ResponseWriter, orgID string, err error) {
|
||||
if errors.Is(err, ErrManagedElsewhere) {
|
||||
http.Error(w, err.Error()+": "+s.Dir.ManageURL(orgID), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// requireOwner returns true and the caller's email when they own the org;
|
||||
// otherwise it writes the error response and returns false.
|
||||
func (s *Server) requireOwner(w http.ResponseWriter, r *http.Request, orgID string) (string, bool) {
|
||||
if s.Orgs == nil {
|
||||
if s.Dir == nil {
|
||||
http.Error(w, "organizations are not enabled on this server", http.StatusNotFound)
|
||||
return "", false
|
||||
}
|
||||
me := s.requestUser(r)
|
||||
if s.Orgs.Role(orgID, me.Email) != RoleOwner {
|
||||
if s.Dir.Role(orgID, me.Email) != RoleOwner {
|
||||
http.Error(w, "only an organization owner can do that", http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
@@ -427,8 +451,8 @@ func (s *Server) handleOrgRename(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.Orgs.Rename(orgID, req.Name); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
if err := s.Dir.Rename(orgID, req.Name); err != nil {
|
||||
s.writeDirErr(w, orgID, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
@@ -447,8 +471,8 @@ func (s *Server) handleMemberUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.Orgs.SetRole(orgID, r.PathValue("email"), req.Role); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
if err := s.Dir.SetRole(orgID, r.PathValue("email"), req.Role); err != nil {
|
||||
s.writeDirErr(w, orgID, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
@@ -460,8 +484,8 @@ func (s *Server) handleMemberRemove(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireOwner(w, r, orgID); !ok {
|
||||
return
|
||||
}
|
||||
if err := s.Orgs.RemoveMember(orgID, r.PathValue("email")); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
if err := s.Dir.RemoveMember(orgID, r.PathValue("email")); err != nil {
|
||||
s.writeDirErr(w, orgID, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
@@ -473,7 +497,7 @@ func (s *Server) handleInviteList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireOwner(w, r, orgID); !ok {
|
||||
return
|
||||
}
|
||||
invs := s.Orgs.ListInvites(orgID)
|
||||
invs := s.Dir.ListInvites(orgID)
|
||||
out := make([]map[string]any, 0, len(invs))
|
||||
for _, inv := range invs {
|
||||
out = append(out, map[string]any{
|
||||
@@ -491,23 +515,23 @@ func (s *Server) handleInviteRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// Confirm the invite belongs to this org before revoking.
|
||||
inv, ok := s.Orgs.Redeem(r.PathValue("token"))
|
||||
inv, ok := s.Dir.Redeem(r.PathValue("token"))
|
||||
if !ok || inv.Org != orgID {
|
||||
http.Error(w, "no such invite", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.Orgs.RevokeInvite(r.PathValue("token"))
|
||||
s.Dir.RevokeInvite(r.PathValue("token"))
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleInviteCreate mints an invite link. Owners only.
|
||||
func (s *Server) handleInviteCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Orgs == nil {
|
||||
if s.Dir == nil {
|
||||
http.Error(w, "organizations are not enabled on this server", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
orgID := r.PathValue("org")
|
||||
if s.Orgs.Role(orgID, s.requestUser(r).Email) != RoleOwner {
|
||||
if s.Dir.Role(orgID, s.requestUser(r).Email) != RoleOwner {
|
||||
http.Error(w, "only an organization owner can invite", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -524,9 +548,9 @@ func (s *Server) handleInviteCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
ttl = d
|
||||
}
|
||||
inv, err := s.Orgs.CreateInvite(orgID, s.requestUser(r).Email, ttl)
|
||||
inv, err := s.Dir.CreateInvite(orgID, s.requestUser(r).Email, ttl)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
s.writeDirErr(w, orgID, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
@@ -538,7 +562,7 @@ func (s *Server) handleInviteCreate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleInviteAccept joins the signed-in account to the invite's org.
|
||||
func (s *Server) handleInviteAccept(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Orgs == nil {
|
||||
if s.Dir == nil {
|
||||
http.Error(w, "organizations are not enabled on this server", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -547,12 +571,12 @@ func (s *Server) handleInviteAccept(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "sign in to accept an invite", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
inv, ok := s.Orgs.Redeem(r.PathValue("token"))
|
||||
inv, ok := s.Dir.Redeem(r.PathValue("token"))
|
||||
if !ok {
|
||||
http.Error(w, "this invite is invalid or expired", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
org, _ := s.Orgs.Get(inv.Org)
|
||||
org, _ := s.Dir.Get(inv.Org)
|
||||
if org.Members[normEmail(me.Email)] == "" {
|
||||
if err := s.quota().CheckSeat(inv.Org, len(org.Members)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
@@ -560,12 +584,12 @@ func (s *Server) handleInviteAccept(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
newMember := org.Members[normEmail(me.Email)] == ""
|
||||
if err := s.Orgs.AddMember(inv.Org, me.Email, RoleMember); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
if err := s.Dir.AddMember(inv.Org, me.Email, RoleMember); err != nil {
|
||||
s.writeDirErr(w, inv.Org, err)
|
||||
return
|
||||
}
|
||||
if newMember {
|
||||
s.Orgs.RecordInviteUse(r.PathValue("token"))
|
||||
s.Dir.RecordInviteUse(r.PathValue("token"))
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true, "org": map[string]string{"id": org.ID, "name": org.Name}})
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ func orgHubSrv(t *testing.T) (h http.Handler, srv *Server, alice, bob *http.Cook
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.Orgs = orgs
|
||||
srv.Dir = LocalDirectory{OrgDB: orgs}
|
||||
shares, err := OpenShareDB(filepath.Join(t.TempDir(), "shares.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -280,3 +280,117 @@ func TestProjectNamesScopedToOrg(t *testing.T) {
|
||||
}
|
||||
_ = alice
|
||||
}
|
||||
|
||||
// The account menu follows manage_url without deciding anything, so the
|
||||
// server has to hand out a usable destination in both deployments: its own
|
||||
// org page by default, whatever the deployment says when orgs live in an
|
||||
// external directory.
|
||||
func TestOrgManageURL(t *testing.T) {
|
||||
h, srv, alice, _, pa := orgHubSrv(t)
|
||||
|
||||
orgOf := func(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
rec := doAs(t, h, "GET", "/api/orgs", nil, alice)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("GET /api/orgs: %d", rec.Code)
|
||||
}
|
||||
var out struct {
|
||||
Orgs []map[string]any `json:"orgs"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Orgs) != 1 {
|
||||
t.Fatalf("orgs = %d, want 1", len(out.Orgs))
|
||||
}
|
||||
return out.Orgs[0]
|
||||
}
|
||||
|
||||
// Self-hosted: the hub's own org page, which the SPA serves.
|
||||
if got, want := orgOf(t)["manage_url"], "/orgs/"+pa.Org; got != want {
|
||||
t.Errorf("manage_url = %v, want %v", got, want)
|
||||
}
|
||||
rec := doAs(t, h, "GET", "/orgs/"+pa.Org, nil, alice)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<div id=\"root\">") {
|
||||
t.Errorf("GET /orgs/<id> = %d, want the SPA shell", rec.Code)
|
||||
}
|
||||
|
||||
// Managed: an external directory owns the org, so the link leaves.
|
||||
srv.Dir = externalDir{Directory: srv.Dir}
|
||||
if got, want := orgOf(t)["manage_url"], "https://auth.example.com/org/members/pa-"+pa.Org; got != want {
|
||||
t.Errorf("managed manage_url = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// externalDir stands in for a directory whose orgs live somewhere else: it
|
||||
// reads like the local one but administration happens off-hub.
|
||||
type externalDir struct{ Directory }
|
||||
|
||||
func (externalDir) ManageURL(orgID string) string {
|
||||
return "https://auth.example.com/org/members/pa-" + orgID
|
||||
}
|
||||
|
||||
// A directory that does not own its organizations turns every org write into
|
||||
// 409 plus the page that does own them. This is what replaces per-deployment
|
||||
// route blocking: the hub answers generically and never learns why.
|
||||
func TestReadOnlyDirectoryRefusesWrites(t *testing.T) {
|
||||
h, srv, alice, bob, pa := orgHubSrv(t)
|
||||
srv.Dir = readOnlyDir{Directory: srv.Dir}
|
||||
|
||||
writes := []struct {
|
||||
method, url string
|
||||
body any
|
||||
}{
|
||||
{"PATCH", "/api/orgs/" + pa.Org, map[string]string{"name": "nope"}},
|
||||
{"PATCH", "/api/orgs/" + pa.Org + "/members/bob@x.io", map[string]string{"role": "owner"}},
|
||||
{"DELETE", "/api/orgs/" + pa.Org + "/members/bob@x.io", nil},
|
||||
{"POST", "/api/orgs/" + pa.Org + "/invites", nil},
|
||||
}
|
||||
for _, tc := range writes {
|
||||
rec := doAs(t, h, tc.method, tc.url, tc.body, alice)
|
||||
if rec.Code != 409 {
|
||||
t.Errorf("%s %s = %d, want 409", tc.method, tc.url, rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "https://elsewhere.example/") {
|
||||
t.Errorf("%s %s body = %q, want the manage URL", tc.method, tc.url, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// Creating a project when you have no org is a write too: the hub cannot
|
||||
// invent one, and the answer has to point somewhere useful rather than
|
||||
// 403ing about an organization that does not exist.
|
||||
bobRec := doAs(t, h, "POST", "/api/projects", map[string]string{"name": "fresh"}, bob)
|
||||
if bobRec.Code != 409 {
|
||||
t.Errorf("project create with no org = %d, want 409", bobRec.Code)
|
||||
}
|
||||
if !strings.Contains(bobRec.Body.String(), "https://elsewhere.example/") {
|
||||
t.Errorf("project create body = %q, want the manage URL", bobRec.Body)
|
||||
}
|
||||
|
||||
// Reads are unaffected: the org still lists, with the external link.
|
||||
rec := doAs(t, h, "GET", "/api/orgs", nil, alice)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "https://elsewhere.example/") {
|
||||
t.Errorf("GET /api/orgs = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// On a hub whose orgs live elsewhere, the org page must not be reachable at
|
||||
// all — a bookmark or a typed URL would otherwise paint a live owner console
|
||||
// whose every control 409s.
|
||||
func TestOrgPageRedirectsWhenManagedElsewhere(t *testing.T) {
|
||||
h, srv, alice, _, pa := orgHubSrv(t)
|
||||
|
||||
// Hub-owned: the SPA shell, as before.
|
||||
if rec := doAs(t, h, "GET", "/orgs/"+pa.Org, nil, alice); rec.Code != 200 {
|
||||
t.Fatalf("self-hosted /orgs/<id> = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
srv.Dir = readOnlyDir{Directory: srv.Dir}
|
||||
rec := doAs(t, h, "GET", "/orgs/"+pa.Org, nil, alice)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("managed /orgs/<id> = %d, want 302", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "https://elsewhere.example/"+pa.Org {
|
||||
t.Errorf("Location = %q, want the provider's page", loc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ package webapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -74,8 +75,12 @@ type Server struct {
|
||||
// Reads, when set, aggregates read telemetry (viewer, share, and agent
|
||||
// reads) for the heat API. Nil means read tracking is off.
|
||||
Reads *ReadLedger
|
||||
// Orgs, when set, walls projects off by organization membership.
|
||||
Orgs *OrgDB
|
||||
// Dir, when set, walls projects off by organization membership and owns
|
||||
// every org read and write the hub performs. LocalDirectory is the
|
||||
// built-in implementation; a managed deployment supplies its own so that
|
||||
// orgs come from the same place identities do. Nil means single-volume
|
||||
// mode: no orgs, every authenticated request passes.
|
||||
Dir Directory
|
||||
// Quota, when set, enforces plan limits (managed deployments). Nil
|
||||
// means UnlimitedQuota: the open-source server never says no.
|
||||
Quota QuotaProvider
|
||||
@@ -399,6 +404,17 @@ func (s *Server) frontend(static fs.FS) http.HandlerFunc {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// A hub whose organizations live elsewhere has no org page to show:
|
||||
// send the browser where they are actually administered rather than
|
||||
// painting a console whose every control would 409. The account menu
|
||||
// already links to the same place; this covers bookmarks, history, and
|
||||
// hand-typed URLs, which are the paths a link cannot reach.
|
||||
if id, ok := strings.CutPrefix(upath, "orgs/"); ok && s.Dir != nil {
|
||||
if u := s.Dir.ManageURL(id); !strings.HasPrefix(u, "/") {
|
||||
http.Redirect(w, r, u, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
if upath != "" && upath != "index.html" {
|
||||
if f, err := static.Open(upath); err == nil {
|
||||
fi, statErr := f.Stat()
|
||||
@@ -430,10 +446,14 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
// show the admin surfaces. Never leak more than these booleans.
|
||||
me := s.requestUser(r)
|
||||
brand := ""
|
||||
if a := s.builtinAuth(); a != nil {
|
||||
auth["allow_signup"] = a.AllowSignup
|
||||
if a, ok := s.Auth.(AccountApprover); ok {
|
||||
// Only a hub that owns its accounts can offer self-signup or an admin
|
||||
// queue; one whose identities come from elsewhere offers neither.
|
||||
auth["allow_signup"] = a.Policy().AllowSignup
|
||||
auth["admin"] = me.Admin
|
||||
brand = a.Brand
|
||||
}
|
||||
if b, ok := s.Auth.(Brander); ok {
|
||||
brand = b.Branding()
|
||||
}
|
||||
if brand == "" {
|
||||
brand = s.Volume
|
||||
@@ -504,6 +524,13 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
org, err := s.orgForCreate(r, req.Org)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrManagedElsewhere) {
|
||||
// A user with no organization on a hub that cannot create one:
|
||||
// send them where organizations actually come from, rather than a
|
||||
// 403 naming an org that does not exist.
|
||||
s.writeDirErr(w, "", err)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -520,17 +547,17 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
|
||||
// for an account in no org yet — a fresh org named after the account, so
|
||||
// nobody is ever blocked from starting to sync. Orgs disabled → "".
|
||||
func (s *Server) orgForCreate(r *http.Request, requested string) (string, error) {
|
||||
if s.Orgs == nil || s.Auth == nil {
|
||||
if s.Dir == nil || s.Auth == nil {
|
||||
return "", nil
|
||||
}
|
||||
me := s.requestUser(r)
|
||||
if requested != "" {
|
||||
if s.Orgs.Role(requested, me.Email) == "" {
|
||||
if s.Dir.Role(requested, me.Email) == "" {
|
||||
return "", fmt.Errorf("you are not a member of organization %q", requested)
|
||||
}
|
||||
return requested, nil
|
||||
}
|
||||
mine := s.Orgs.OrgsFor(me.Email)
|
||||
mine := s.Dir.OrgsFor(me.Email)
|
||||
if len(mine) > 0 {
|
||||
return mine[0].ID, nil
|
||||
}
|
||||
@@ -538,7 +565,7 @@ func (s *Server) orgForCreate(r *http.Request, requested string) (string, error)
|
||||
if name == "" {
|
||||
name = strings.SplitN(me.Email, "@", 2)[0]
|
||||
}
|
||||
o, err := s.Orgs.Create(name, me.Email)
|
||||
o, err := s.Dir.Create(name, me.Email)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-DrDCfi8U.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CeGJW3sC.css">
|
||||
<script type="module" crossorigin src="/assets/index-Bh_89TVP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-u-8Gj7e3.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||