Orgs: let any user create an organization and become its admin
Organization writes were superadmin-only, so standing up a tenant needed an out-of-band superadmin. Creating one is now self-service, and an admin manages the org they belong to. - POST /api/orgs is open to any authenticated user. A creator who isn't a superadmin must have no organization yet (a single-valued membership relation means a second one would abandon the first), and is promoted to the new org's admin and first member in the same request. If that promotion fails the org is rolled back, so it is never left stranded with nobody able to administer it. Superadmins still create tenants without joining them. - PATCH/DELETE are manager-gated and scope an admin to their own org. An admin deletes theirs only as its sole member: they are detached and demoted to a plain user before the record goes, so the org is empty when it is removed. Other members still block deletion with a 409. - /api/me now carries organization + organizationName, which the clients need to tell "no org yet" from "org you administer". The panel, Web App (new OrgManager.vue in Settings) and Phone App (new _OrganizationSection) all mirror the server's gates rather than re-deciding them. The Phone App cached its role at login and gates the Users tab on it, so AuthService.adoptRole refreshes that from the profile instead of making a freshly promoted admin sign in again. Covered by orgs_test.go, which drives the real handler + middleware chain against a stand-in PocketBase: promotion, the already-a-member refusal, superadmin staying unattached, the rollback, own-org scoping, the detach-and-demote, and the blocking-member 409. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
358ee68f94
commit
cd16d4383f
+17
-1
@@ -76,6 +76,22 @@ their own account. An organization cannot be deleted while it still has members.
|
|||||||
membership. A superadmin spans all organizations; an admin is scoped by the
|
membership. A superadmin spans all organizations; an admin is scoped by the
|
||||||
server to their own. Users may have no organization at all.
|
server to their own. Users may have no organization at all.
|
||||||
|
|
||||||
|
Creating one is self-service: any user who does not already belong to an
|
||||||
|
organization may `POST /api/orgs`, and becomes that organization's **admin** and
|
||||||
|
first member in the same request (if the promotion fails the new organization is
|
||||||
|
rolled back, so it is never left with nobody able to administer it). A user who
|
||||||
|
already belongs to one is refused — membership is a single relation, so creating
|
||||||
|
a second would mean silently abandoning the first.
|
||||||
|
|
||||||
|
A superadmin is the exception: they create organizations without joining them,
|
||||||
|
since they already span every tenant.
|
||||||
|
|
||||||
|
From there an admin manages **their own** organization — rename it, or delete it
|
||||||
|
once they are its only member. Deleting it detaches and demotes them back to a
|
||||||
|
plain `user` before the record is removed, so the organization is empty when it
|
||||||
|
goes. A superadmin may rename or delete any organization, but still only once it
|
||||||
|
has no members at all.
|
||||||
|
|
||||||
### Per-user car ownership + sharing
|
### Per-user car ownership + sharing
|
||||||
|
|
||||||
Cars are not a global list. `cars.owner` marks ownership and `car_shares` grants
|
Cars are not a global list. `cars.owner` marks ownership and `car_shares` grants
|
||||||
@@ -139,7 +155,7 @@ POST /api/me/verify/request
|
|||||||
GET /api/me/export POST /api/me/import
|
GET /api/me/export POST /api/me/import
|
||||||
POST /api/me/delete POST /api/me/delete/cancel
|
POST /api/me/delete POST /api/me/delete/cancel
|
||||||
|
|
||||||
# users + organizations (admin or superadmin; org writes are superadmin-only)
|
# users + organizations (admin or superadmin; POST /api/orgs is open to any user)
|
||||||
GET /api/users POST /api/users
|
GET /api/users POST /api/users
|
||||||
PATCH /api/users/{id} DELETE /api/users/{id}
|
PATCH /api/users/{id} DELETE /api/users/{id}
|
||||||
GET /api/orgs POST /api/orgs
|
GET /api/orgs POST /api/orgs
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#2563eb" />
|
<meta name="theme-color" content="#2563eb" />
|
||||||
<title>DriverVault · API Server</title>
|
<title>DriverVault · API Server</title>
|
||||||
<script type="module" crossorigin src="/assets/index-D-rTgBao.js"></script>
|
<script type="module" crossorigin src="/assets/index-OHZkxAdj.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D3MeNcl7.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D3MeNcl7.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type userRecord struct {
|
|||||||
FontSize string `json:"font_size"`
|
FontSize string `json:"font_size"`
|
||||||
DeletionRequestedAt string `json:"deletion_requested_at"`
|
DeletionRequestedAt string `json:"deletion_requested_at"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
Organization string `json:"organization"`
|
||||||
Created string `json:"created"`
|
Created string `json:"created"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,8 @@ func (rec userRecord) toModel() models.User {
|
|||||||
FontSize: orDefault(rec.FontSize, "medium"),
|
FontSize: orDefault(rec.FontSize, "medium"),
|
||||||
Role: orDefault(rec.Role, "user"),
|
Role: orDefault(rec.Role, "user"),
|
||||||
Created: rec.Created,
|
Created: rec.Created,
|
||||||
|
|
||||||
|
Organization: rec.Organization,
|
||||||
}
|
}
|
||||||
if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() {
|
if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() {
|
||||||
u.DeletionRequestedAt = &t
|
u.DeletionRequestedAt = &t
|
||||||
@@ -65,6 +68,17 @@ func orDefault(v, fallback string) string {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// profileOf builds the profile payload for a user record, resolving their
|
||||||
|
// organization's name so the clients can label the membership without a second
|
||||||
|
// round trip. The lookup is best effort — an unresolvable name leaves the id.
|
||||||
|
func (s *Server) profileOf(r *http.Request, rec *userRecord) models.User {
|
||||||
|
u := rec.toModel()
|
||||||
|
if u.Organization != "" {
|
||||||
|
u.OrganizationName = s.orgName(r.Context(), u.Organization)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) fetchUser(r *http.Request, id string) (*userRecord, error) {
|
func (s *Server) fetchUser(r *http.Request, id string) (*userRecord, error) {
|
||||||
var rec userRecord
|
var rec userRecord
|
||||||
if err := s.pb.GetOne(r.Context(), s.usersCollection(), id, &rec); err != nil {
|
if err := s.pb.GetOne(r.Context(), s.usersCollection(), id, &rec); err != nil {
|
||||||
@@ -84,7 +98,7 @@ func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
writePBError(w, err)
|
writePBError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, rec.toModel())
|
writeJSON(w, http.StatusOK, s.profileOf(r, rec))
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateMeRequest struct {
|
type updateMeRequest struct {
|
||||||
@@ -181,7 +195,7 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
writePBError(w, err)
|
writePBError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, rec.toModel())
|
writeJSON(w, http.StatusOK, s.profileOf(r, &rec))
|
||||||
}
|
}
|
||||||
|
|
||||||
type changePasswordRequest struct {
|
type changePasswordRequest struct {
|
||||||
@@ -256,7 +270,7 @@ func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
|
|||||||
writePBError(w, err)
|
writePBError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, rec.toModel())
|
writeJSON(w, http.StatusOK, s.profileOf(r, rec))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -81,8 +81,24 @@ func (s *Server) handleListOrgs(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"organizations": list.Items})
|
writeJSON(w, http.StatusOK, map[string]any{"organizations": list.Items})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/orgs — create an organization (superadmin only). Body: {name}.
|
// POST /api/orgs — create an organization. Body: {name}. A superadmin may create
|
||||||
|
// any number of organizations and is not attached to them (they manage every
|
||||||
|
// tenant centrally). Any other user may create one only if they don't already
|
||||||
|
// belong to an organization, and they become its admin and first member — the
|
||||||
|
// self-service path for standing up a new tenant.
|
||||||
|
//
|
||||||
|
// This handler carries no requireRole gate (any authenticated caller may reach
|
||||||
|
// it), so it checks the service account itself.
|
||||||
func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.pb.Configured() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "organizations are not configured on the server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
who := caller(r)
|
||||||
|
if !who.isSuperadmin() && who.OrgID != "" {
|
||||||
|
writeError(w, http.StatusForbidden, "you already belong to an organization")
|
||||||
|
return
|
||||||
|
}
|
||||||
name, ok := decodeOrgName(w, r)
|
name, ok := decodeOrgName(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -100,16 +116,42 @@ func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
var org orgView
|
var org orgView
|
||||||
_ = json.Unmarshal(data, &org)
|
_ = json.Unmarshal(data, &org)
|
||||||
|
|
||||||
|
// A non-superadmin creator becomes the admin of, and first member of, the org
|
||||||
|
// they just made. If that promotion fails, roll the org back so we never leave
|
||||||
|
// a stranded organization that nobody can administer.
|
||||||
|
if !who.isSuperadmin() {
|
||||||
|
data, status, err = s.pb.Raw(r.Context(), http.MethodPatch,
|
||||||
|
"/api/collections/"+s.usersCollection()+"/records/"+url.PathEscape(who.ID),
|
||||||
|
map[string]any{"organization": org.ID, "role": roleAdmin})
|
||||||
|
if err != nil || status != http.StatusOK {
|
||||||
|
_, _, _ = s.pb.Raw(r.Context(), http.MethodDelete,
|
||||||
|
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(org.ID), nil)
|
||||||
|
if err != nil {
|
||||||
|
writeUpstreamDown(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
relay(w, status, data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{"organization": org})
|
writeJSON(w, http.StatusCreated, map[string]any{"organization": org})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/orgs/{id} — rename an organization (superadmin only). Body: {name}.
|
// PATCH /api/orgs/{id} — rename an organization (manager only). A superadmin may
|
||||||
|
// rename any organization; an admin may rename only their own. Body: {name}.
|
||||||
func (s *Server) handleUpdateOrg(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUpdateOrg(w http.ResponseWriter, r *http.Request) {
|
||||||
|
who := caller(r)
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
if id == "" {
|
if id == "" {
|
||||||
writeError(w, http.StatusBadRequest, "missing organization id")
|
writeError(w, http.StatusBadRequest, "missing organization id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// An admin is scoped to their own organization; a superadmin spans all.
|
||||||
|
if !who.isSuperadmin() && (who.OrgID == "" || id != who.OrgID) {
|
||||||
|
writeError(w, http.StatusForbidden, "you can only rename your own organization")
|
||||||
|
return
|
||||||
|
}
|
||||||
name, ok := decodeOrgName(w, r)
|
name, ok := decodeOrgName(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -129,18 +171,35 @@ func (s *Server) handleUpdateOrg(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"organization": org})
|
writeJSON(w, http.StatusOK, map[string]any{"organization": org})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/orgs/{id} — delete an organization (superadmin only). Refused
|
// DELETE /api/orgs/{id} — delete an organization (manager only). A superadmin may
|
||||||
// while the org still has members, to avoid silently orphaning users.
|
// delete any organization, but only once it has no members. An admin may delete
|
||||||
|
// only their own organization, and only when they are its sole member: the admin
|
||||||
|
// is detached and demoted back to a plain user, then the org is removed. Either
|
||||||
|
// path refuses to silently orphan other members.
|
||||||
func (s *Server) handleDeleteOrg(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteOrg(w http.ResponseWriter, r *http.Request) {
|
||||||
|
who := caller(r)
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
if id == "" {
|
if id == "" {
|
||||||
writeError(w, http.StatusBadRequest, "missing organization id")
|
writeError(w, http.StatusBadRequest, "missing organization id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard: block deletion if any user still belongs to this org.
|
// Count the members that block deletion. A superadmin is blocked by anyone at
|
||||||
|
// all; an admin deleting their own org is blocked only by *other* members,
|
||||||
|
// since they detach themselves below.
|
||||||
|
filter := "organization = \"" + id + "\""
|
||||||
|
blocked := "organization still has members; reassign or remove them first"
|
||||||
|
if !who.isSuperadmin() {
|
||||||
|
// An admin is scoped to their own organization.
|
||||||
|
if who.OrgID == "" || id != who.OrgID {
|
||||||
|
writeError(w, http.StatusForbidden, "you can only delete your own organization")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter += " && id != \"" + who.ID + "\""
|
||||||
|
blocked = "organization still has other members; reassign or remove them first"
|
||||||
|
}
|
||||||
countPath := "/api/collections/" + s.usersCollection() + "/records?perPage=1&fields=id&filter=" +
|
countPath := "/api/collections/" + s.usersCollection() + "/records?perPage=1&fields=id&filter=" +
|
||||||
url.QueryEscape("organization = \""+id+"\"")
|
url.QueryEscape(filter)
|
||||||
data, status, err := s.pb.Raw(r.Context(), http.MethodGet, countPath, nil)
|
data, status, err := s.pb.Raw(r.Context(), http.MethodGet, countPath, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeUpstreamDown(w, err)
|
writeUpstreamDown(w, err)
|
||||||
@@ -152,7 +211,25 @@ func (s *Server) handleDeleteOrg(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
_ = json.Unmarshal(data, &page)
|
_ = json.Unmarshal(data, &page)
|
||||||
if page.TotalItems > 0 {
|
if page.TotalItems > 0 {
|
||||||
writeError(w, http.StatusConflict, "organization still has members; reassign or remove them first")
|
writeError(w, http.StatusConflict, blocked)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach and demote the admin so the org is empty before it is removed. Done
|
||||||
|
// before the delete because users.organization does not cascade: a failed
|
||||||
|
// delete leaves an empty org a superadmin can clean up, which beats leaving a
|
||||||
|
// member pointing at an organization that no longer exists.
|
||||||
|
if !who.isSuperadmin() {
|
||||||
|
data, status, err = s.pb.Raw(r.Context(), http.MethodPatch,
|
||||||
|
"/api/collections/"+s.usersCollection()+"/records/"+url.PathEscape(who.ID),
|
||||||
|
map[string]any{"organization": "", "role": roleUser})
|
||||||
|
if err != nil {
|
||||||
|
writeUpstreamDown(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
relay(w, status, data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"drivervault/apiserver/internal/config"
|
||||||
|
"drivervault/apiserver/internal/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests cover the self-service organization flows through the real
|
||||||
|
// Handler + middleware chain: an org-less user creating an org and being
|
||||||
|
// promoted to its admin (including the rollback when that promotion fails), and
|
||||||
|
// an admin renaming or deleting only their own organization. A stand-in
|
||||||
|
// PocketBase (orgFakePB) serves exactly the endpoints those paths hit.
|
||||||
|
|
||||||
|
const (
|
||||||
|
orgTestUserID = "u1"
|
||||||
|
orgTestBearer = "user-bearer-token"
|
||||||
|
orgTestNewID = "neworg1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// orgFakePB answers the identity, organization and user-record calls the org
|
||||||
|
// handlers make, and records the writes so tests can assert on them.
|
||||||
|
type orgFakePB struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// Identity returned by auth-refresh.
|
||||||
|
callerRole string
|
||||||
|
callerOrg string
|
||||||
|
|
||||||
|
// Behaviour switches.
|
||||||
|
userPatchStatus int // status for a caller promotion/demotion (200 when 0)
|
||||||
|
memberCount int // totalItems for the blocking-member query
|
||||||
|
|
||||||
|
// Recorded effects.
|
||||||
|
createdOrgs []string
|
||||||
|
orgPatches []map[string]any
|
||||||
|
orgDeletes []string
|
||||||
|
userPatches []map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *orgFakePB) handler(t *testing.T) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Service-account auth (pb.Client.Authenticate).
|
||||||
|
mux.HandleFunc("POST /api/collections/_superusers/auth-with-password", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, 200, map[string]any{"token": "svc-token"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Identify the bearer (withAuth → identify → AuthRefresh).
|
||||||
|
mux.HandleFunc("POST /api/collections/users/auth-refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") == "" {
|
||||||
|
writeJSON(w, 401, map[string]any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
writeJSON(w, 200, map[string]any{"record": map[string]any{
|
||||||
|
"id": orgTestUserID, "email": "owner@test.local", "name": "Owner",
|
||||||
|
"role": f.callerRole, "organization": f.callerOrg,
|
||||||
|
}})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Organization create / rename / delete.
|
||||||
|
mux.HandleFunc("POST /api/collections/organizations/records", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
f.mu.Lock()
|
||||||
|
f.createdOrgs = append(f.createdOrgs, orgTestNewID)
|
||||||
|
f.mu.Unlock()
|
||||||
|
writeJSON(w, 200, map[string]any{"id": orgTestNewID, "name": body["name"], "created": "2026-08-17 10:00:00Z"})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("PATCH /api/collections/organizations/records/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
f.mu.Lock()
|
||||||
|
f.orgPatches = append(f.orgPatches, body)
|
||||||
|
f.mu.Unlock()
|
||||||
|
writeJSON(w, 200, map[string]any{"id": r.PathValue("id"), "name": body["name"]})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("DELETE /api/collections/organizations/records/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.orgDeletes = append(f.orgDeletes, r.PathValue("id"))
|
||||||
|
f.mu.Unlock()
|
||||||
|
writeJSON(w, 204, nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Blocking-member count for a delete.
|
||||||
|
mux.HandleFunc("GET /api/collections/users/records", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
writeJSON(w, 200, map[string]any{"totalItems": f.memberCount, "items": []any{}})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Caller promotion / demotion.
|
||||||
|
mux.HandleFunc("PATCH /api/collections/users/records/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
f.mu.Lock()
|
||||||
|
f.userPatches = append(f.userPatches, body)
|
||||||
|
status := f.userPatchStatus
|
||||||
|
f.mu.Unlock()
|
||||||
|
if status != 0 && status != 200 {
|
||||||
|
writeJSON(w, status, map[string]any{"message": "cannot update user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"id": r.PathValue("id")})
|
||||||
|
})
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// newOrgTestServer wires the fake PocketBase to a real Handler and returns the
|
||||||
|
// app's base URL.
|
||||||
|
func newOrgTestServer(t *testing.T, f *orgFakePB) string {
|
||||||
|
t.Helper()
|
||||||
|
pbSrv := httptest.NewServer(f.handler(t))
|
||||||
|
t.Cleanup(pbSrv.Close)
|
||||||
|
|
||||||
|
s := New(config.Config{UsersCollection: "users"}, pb.New(pbSrv.URL, "admin@test.local", "pw"))
|
||||||
|
appSrv := httptest.NewServer(s.Handler())
|
||||||
|
t.Cleanup(appSrv.Close)
|
||||||
|
return appSrv.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// orgReq issues an authenticated request and returns the status and decoded body.
|
||||||
|
func orgReq(t *testing.T, base, method, path string, body any) (int, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if body != nil {
|
||||||
|
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, base+path, &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+orgTestBearer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
out := map[string]any{}
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
return resp.StatusCode, out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOrgPromotesCreatorToAdmin(t *testing.T) {
|
||||||
|
f := &orgFakePB{callerRole: roleUser, callerOrg: ""}
|
||||||
|
base := newOrgTestServer(t, f)
|
||||||
|
|
||||||
|
status, body := orgReq(t, base, http.MethodPost, "/api/orgs", map[string]any{"name": "Acme Inc."})
|
||||||
|
if status != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201 (body %v)", status, body)
|
||||||
|
}
|
||||||
|
org, _ := body["organization"].(map[string]any)
|
||||||
|
if org["id"] != orgTestNewID || org["name"] != "Acme Inc." {
|
||||||
|
t.Fatalf("organization = %v, want id %q name %q", org, orgTestNewID, "Acme Inc.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(f.userPatches) != 1 {
|
||||||
|
t.Fatalf("user patches = %d, want 1 (%v)", len(f.userPatches), f.userPatches)
|
||||||
|
}
|
||||||
|
patch := f.userPatches[0]
|
||||||
|
if patch["organization"] != orgTestNewID {
|
||||||
|
t.Errorf("patched organization = %v, want %q", patch["organization"], orgTestNewID)
|
||||||
|
}
|
||||||
|
if patch["role"] != roleAdmin {
|
||||||
|
t.Errorf("patched role = %v, want %q", patch["role"], roleAdmin)
|
||||||
|
}
|
||||||
|
if len(f.orgDeletes) != 0 {
|
||||||
|
t.Errorf("org was rolled back unexpectedly: %v", f.orgDeletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOrgRejectedWhenCallerAlreadyHasOne(t *testing.T) {
|
||||||
|
f := &orgFakePB{callerRole: roleUser, callerOrg: "existing1"}
|
||||||
|
base := newOrgTestServer(t, f)
|
||||||
|
|
||||||
|
status, body := orgReq(t, base, http.MethodPost, "/api/orgs", map[string]any{"name": "Second Org"})
|
||||||
|
if status != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403 (body %v)", status, body)
|
||||||
|
}
|
||||||
|
if len(f.createdOrgs) != 0 {
|
||||||
|
t.Errorf("created orgs = %v, want none", f.createdOrgs)
|
||||||
|
}
|
||||||
|
if len(f.userPatches) != 0 {
|
||||||
|
t.Errorf("user patches = %v, want none", f.userPatches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A superadmin manages every tenant centrally, so creating one must not move
|
||||||
|
// them into it or change their role.
|
||||||
|
func TestCreateOrgLeavesSuperadminUnattached(t *testing.T) {
|
||||||
|
f := &orgFakePB{callerRole: roleSuperadmin, callerOrg: ""}
|
||||||
|
base := newOrgTestServer(t, f)
|
||||||
|
|
||||||
|
status, body := orgReq(t, base, http.MethodPost, "/api/orgs", map[string]any{"name": "Tenant A"})
|
||||||
|
if status != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201 (body %v)", status, body)
|
||||||
|
}
|
||||||
|
if len(f.userPatches) != 0 {
|
||||||
|
t.Errorf("user patches = %v, want none for a superadmin", f.userPatches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the creator cannot be promoted, the new org must not survive — otherwise it
|
||||||
|
// is stranded with nobody able to administer it.
|
||||||
|
func TestCreateOrgRollsBackWhenPromotionFails(t *testing.T) {
|
||||||
|
f := &orgFakePB{callerRole: roleUser, callerOrg: "", userPatchStatus: http.StatusBadRequest}
|
||||||
|
base := newOrgTestServer(t, f)
|
||||||
|
|
||||||
|
status, _ := orgReq(t, base, http.MethodPost, "/api/orgs", map[string]any{"name": "Doomed"})
|
||||||
|
if status == http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want a failure", status)
|
||||||
|
}
|
||||||
|
if len(f.orgDeletes) != 1 || f.orgDeletes[0] != orgTestNewID {
|
||||||
|
t.Errorf("org deletes = %v, want rollback of %q", f.orgDeletes, orgTestNewID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOrgRejectsBadName(t *testing.T) {
|
||||||
|
f := &orgFakePB{callerRole: roleUser, callerOrg: ""}
|
||||||
|
base := newOrgTestServer(t, f)
|
||||||
|
|
||||||
|
status, _ := orgReq(t, base, http.MethodPost, "/api/orgs", map[string]any{"name": " "})
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", status)
|
||||||
|
}
|
||||||
|
if len(f.createdOrgs) != 0 {
|
||||||
|
t.Errorf("created orgs = %v, want none", f.createdOrgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateOrgAdminScopedToOwnOrg(t *testing.T) {
|
||||||
|
f := &orgFakePB{callerRole: roleAdmin, callerOrg: "o1"}
|
||||||
|
base := newOrgTestServer(t, f)
|
||||||
|
|
||||||
|
// Someone else's organization is off limits.
|
||||||
|
status, _ := orgReq(t, base, http.MethodPatch, "/api/orgs/o2", map[string]any{"name": "Hijack"})
|
||||||
|
if status != http.StatusForbidden {
|
||||||
|
t.Fatalf("foreign org status = %d, want 403", status)
|
||||||
|
}
|
||||||
|
if len(f.orgPatches) != 0 {
|
||||||
|
t.Fatalf("org patches = %v, want none", f.orgPatches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Their own is fine.
|
||||||
|
status, body := orgReq(t, base, http.MethodPatch, "/api/orgs/o1", map[string]any{"name": "Renamed"})
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("own org status = %d, want 200 (body %v)", status, body)
|
||||||
|
}
|
||||||
|
if len(f.orgPatches) != 1 || f.orgPatches[0]["name"] != "Renamed" {
|
||||||
|
t.Errorf("org patches = %v, want one rename to %q", f.orgPatches, "Renamed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An admin deleting their own org is detached and demoted first, so the org is
|
||||||
|
// empty by the time it is removed.
|
||||||
|
func TestDeleteOrgAdminSoleMemberIsDetachedAndDemoted(t *testing.T) {
|
||||||
|
f := &orgFakePB{callerRole: roleAdmin, callerOrg: "o1", memberCount: 0}
|
||||||
|
base := newOrgTestServer(t, f)
|
||||||
|
|
||||||
|
status, body := orgReq(t, base, http.MethodDelete, "/api/orgs/o1", nil)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (body %v)", status, body)
|
||||||
|
}
|
||||||
|
if len(f.userPatches) != 1 {
|
||||||
|
t.Fatalf("user patches = %d, want 1 (%v)", len(f.userPatches), f.userPatches)
|
||||||
|
}
|
||||||
|
patch := f.userPatches[0]
|
||||||
|
if patch["organization"] != "" {
|
||||||
|
t.Errorf("patched organization = %v, want cleared", patch["organization"])
|
||||||
|
}
|
||||||
|
if patch["role"] != roleUser {
|
||||||
|
t.Errorf("patched role = %v, want %q", patch["role"], roleUser)
|
||||||
|
}
|
||||||
|
if len(f.orgDeletes) != 1 || f.orgDeletes[0] != "o1" {
|
||||||
|
t.Errorf("org deletes = %v, want [o1]", f.orgDeletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteOrgBlockedByOtherMembers(t *testing.T) {
|
||||||
|
f := &orgFakePB{callerRole: roleAdmin, callerOrg: "o1", memberCount: 2}
|
||||||
|
base := newOrgTestServer(t, f)
|
||||||
|
|
||||||
|
status, _ := orgReq(t, base, http.MethodDelete, "/api/orgs/o1", nil)
|
||||||
|
if status != http.StatusConflict {
|
||||||
|
t.Fatalf("status = %d, want 409", status)
|
||||||
|
}
|
||||||
|
if len(f.orgDeletes) != 0 {
|
||||||
|
t.Errorf("org deletes = %v, want none", f.orgDeletes)
|
||||||
|
}
|
||||||
|
if len(f.userPatches) != 0 {
|
||||||
|
t.Errorf("user patches = %v, want none — the admin must stay put", f.userPatches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteOrgAdminCannotDeleteForeignOrg(t *testing.T) {
|
||||||
|
f := &orgFakePB{callerRole: roleAdmin, callerOrg: "o1"}
|
||||||
|
base := newOrgTestServer(t, f)
|
||||||
|
|
||||||
|
status, _ := orgReq(t, base, http.MethodDelete, "/api/orgs/o2", nil)
|
||||||
|
if status != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", status)
|
||||||
|
}
|
||||||
|
if len(f.orgDeletes) != 0 {
|
||||||
|
t.Errorf("org deletes = %v, want none", f.orgDeletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plain user is not a manager, so rename/delete stay closed to them even for
|
||||||
|
// the org they belong to.
|
||||||
|
func TestPlainUserCannotManageOrgs(t *testing.T) {
|
||||||
|
f := &orgFakePB{callerRole: roleUser, callerOrg: "o1"}
|
||||||
|
base := newOrgTestServer(t, f)
|
||||||
|
|
||||||
|
if status, _ := orgReq(t, base, http.MethodPatch, "/api/orgs/o1", map[string]any{"name": "Nope"}); status != http.StatusForbidden {
|
||||||
|
t.Errorf("rename status = %d, want 403", status)
|
||||||
|
}
|
||||||
|
if status, _ := orgReq(t, base, http.MethodDelete, "/api/orgs/o1", nil); status != http.StatusForbidden {
|
||||||
|
t.Errorf("delete status = %d, want 403", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
// GET /api/me/export POST /api/me/import
|
// GET /api/me/export POST /api/me/import
|
||||||
// POST /api/me/delete POST /api/me/delete/cancel
|
// POST /api/me/delete POST /api/me/delete/cancel
|
||||||
//
|
//
|
||||||
// # users + organizations (manager; writes to orgs are superadmin-only)
|
// # users + organizations (manager; POST /api/orgs is open to any user)
|
||||||
// GET /api/users POST /api/users
|
// GET /api/users POST /api/users
|
||||||
// PATCH /api/users/{id} DELETE /api/users/{id}
|
// PATCH /api/users/{id} DELETE /api/users/{id}
|
||||||
// GET /api/orgs POST /api/orgs
|
// GET /api/orgs POST /api/orgs
|
||||||
@@ -285,12 +285,15 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("PATCH /api/users/{id}", s.requireManager(s.handleUpdateUser))
|
mux.HandleFunc("PATCH /api/users/{id}", s.requireManager(s.handleUpdateUser))
|
||||||
mux.HandleFunc("DELETE /api/users/{id}", s.requireManager(s.handleDeleteUser))
|
mux.HandleFunc("DELETE /api/users/{id}", s.requireManager(s.handleDeleteUser))
|
||||||
|
|
||||||
// Organizations — listing is manager-scoped; create/edit/delete are
|
// Organizations — the tenants users belong to. Listing is manager-scoped (an
|
||||||
// superadmin-only (a superadmin spans all organizations).
|
// admin sees only their own org). Any org-less user may create an org and
|
||||||
|
// becomes its admin; an admin may rename or delete their own org; a superadmin
|
||||||
|
// spans every organization. Create carries no role gate, so it checks the
|
||||||
|
// service account itself.
|
||||||
mux.HandleFunc("GET /api/orgs", s.requireManager(s.handleListOrgs))
|
mux.HandleFunc("GET /api/orgs", s.requireManager(s.handleListOrgs))
|
||||||
mux.HandleFunc("POST /api/orgs", s.requireSuperadmin(s.handleCreateOrg))
|
mux.HandleFunc("POST /api/orgs", s.handleCreateOrg)
|
||||||
mux.HandleFunc("PATCH /api/orgs/{id}", s.requireSuperadmin(s.handleUpdateOrg))
|
mux.HandleFunc("PATCH /api/orgs/{id}", s.requireManager(s.handleUpdateOrg))
|
||||||
mux.HandleFunc("DELETE /api/orgs/{id}", s.requireSuperadmin(s.handleDeleteOrg))
|
mux.HandleFunc("DELETE /api/orgs/{id}", s.requireManager(s.handleDeleteOrg))
|
||||||
|
|
||||||
// PocketBase connection settings — superadmin only. These do NOT require the
|
// PocketBase connection settings — superadmin only. These do NOT require the
|
||||||
// service account to already be configured (they exist to configure it).
|
// service account to already be configured (they exist to configure it).
|
||||||
|
|||||||
@@ -335,6 +335,11 @@ type User struct {
|
|||||||
FontSize string `json:"fontSize"` // small | medium | large
|
FontSize string `json:"fontSize"` // small | medium | large
|
||||||
Role string `json:"role"` // user | admin
|
Role string `json:"role"` // user | admin
|
||||||
|
|
||||||
|
// Organization membership. Empty when the user belongs to no organization —
|
||||||
|
// the clients use that to offer creating one (which makes them its admin).
|
||||||
|
Organization string `json:"organization"`
|
||||||
|
OrganizationName string `json:"organizationName,omitempty"`
|
||||||
|
|
||||||
// Non-empty while an account-deletion request is pending its cooldown.
|
// Non-empty while an account-deletion request is pending its cooldown.
|
||||||
DeletionRequestedAt *time.Time `json:"deletionRequestedAt,omitempty"`
|
DeletionRequestedAt *time.Time `json:"deletionRequestedAt,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,14 @@ onMounted(async () => {
|
|||||||
const sections = computed(() => {
|
const sections = computed(() => {
|
||||||
const out = [{ id: "overview", label: t("sections.overview") }];
|
const out = [{ id: "overview", label: t("sections.overview") }];
|
||||||
if (isManager.value) {
|
if (isManager.value) {
|
||||||
out.push({ id: "users", label: t("sections.users") }, { id: "orgs", label: t("sections.orgs") });
|
out.push({ id: "users", label: t("sections.users") });
|
||||||
|
}
|
||||||
|
// Organizations is open to everyone: a manager sees the org(s) they manage,
|
||||||
|
// and an org-less user gets the form that stands one up (making them its
|
||||||
|
// admin). Only a caller with neither an org nor a manager role sees nothing
|
||||||
|
// to do there, and they still get the create form.
|
||||||
|
if (isManager.value || me.value) {
|
||||||
|
out.push({ id: "orgs", label: t("sections.orgs") });
|
||||||
}
|
}
|
||||||
if (isSuperadmin.value) {
|
if (isSuperadmin.value) {
|
||||||
out.push(
|
out.push(
|
||||||
@@ -129,9 +136,9 @@ const managementApi = [
|
|||||||
{ method: "PATCH", path: "/api/users/{id}", desc: "Update email / name / role / org / password" },
|
{ method: "PATCH", path: "/api/users/{id}", desc: "Update email / name / role / org / password" },
|
||||||
{ method: "DELETE", path: "/api/users/{id}", desc: "Delete a user" },
|
{ method: "DELETE", path: "/api/users/{id}", desc: "Delete a user" },
|
||||||
{ method: "GET", path: "/api/orgs", desc: "List organizations" },
|
{ method: "GET", path: "/api/orgs", desc: "List organizations" },
|
||||||
{ method: "POST", path: "/api/orgs", desc: "Create an organization (superadmin)" },
|
{ method: "POST", path: "/api/orgs", desc: "Create an organization (any user without one; creator becomes admin)" },
|
||||||
{ method: "PATCH", path: "/api/orgs/{id}", desc: "Rename an organization (superadmin)" },
|
{ method: "PATCH", path: "/api/orgs/{id}", desc: "Rename an organization (own org; any as superadmin)" },
|
||||||
{ method: "DELETE", path: "/api/orgs/{id}", desc: "Delete an organization (superadmin)" },
|
{ method: "DELETE", path: "/api/orgs/{id}", desc: "Delete an organization (own org; any as superadmin)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const superadminApi = [
|
const superadminApi = [
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from "vue";
|
import { computed, ref, onMounted } from "vue";
|
||||||
import { isSuperadmin, request } from "../api";
|
import { isManager, isSuperadmin, loadMe, me, request } from "../api";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
// Listing is manager-scoped (an admin sees only their own org); creating,
|
// Mirrors the server's gates: listing is manager-scoped (an admin sees only
|
||||||
// renaming and deleting are superadmin-only, matching the server's gates.
|
// their own org); a caller with no organization may create one and becomes its
|
||||||
|
// admin; an admin may rename or delete their own org; a superadmin spans all.
|
||||||
const orgs = ref([]);
|
const orgs = ref([]);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const editing = ref(null); // org id, or "new"
|
const editing = ref(null); // org id, or "new"
|
||||||
const draftName = ref("");
|
const draftName = ref("");
|
||||||
|
|
||||||
|
const myOrg = computed(() => me.value?.organization || "");
|
||||||
|
// A superadmin creates tenants freely; anyone else only their first one.
|
||||||
|
const canCreate = computed(() => isSuperadmin.value || !myOrg.value);
|
||||||
|
// An admin manages only their own org; a superadmin manages every org.
|
||||||
|
function canManage(o) {
|
||||||
|
return isSuperadmin.value || o.id === myOrg.value;
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
// Listing is manager-only; an org-less user just gets the create form.
|
||||||
|
if (!isManager.value) {
|
||||||
|
orgs.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const out = await request("/api/orgs");
|
const out = await request("/api/orgs");
|
||||||
orgs.value = out.organizations || [];
|
orgs.value = out.organizations || [];
|
||||||
@@ -43,6 +57,9 @@ async function save() {
|
|||||||
try {
|
try {
|
||||||
if (editing.value === "new") {
|
if (editing.value === "new") {
|
||||||
await request("/api/orgs", { method: "POST", body: { name: draftName.value } });
|
await request("/api/orgs", { method: "POST", body: { name: draftName.value } });
|
||||||
|
// Creating an org as a non-superadmin makes the caller its admin, so the
|
||||||
|
// local identity (role + organization) is now stale.
|
||||||
|
if (!isSuperadmin.value) await loadMe();
|
||||||
} else {
|
} else {
|
||||||
await request(`/api/orgs/${editing.value}`, {
|
await request(`/api/orgs/${editing.value}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -59,14 +76,18 @@ async function save() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(o) {
|
async function remove(o) {
|
||||||
if (!confirm(t("orgs.confirmDelete", { name: o.name }))) return;
|
// Deleting your own org detaches you from it and drops you back to a plain user.
|
||||||
|
const mine = !isSuperadmin.value && o.id === myOrg.value;
|
||||||
|
const key = mine ? "orgs.confirmDeleteOwn" : "orgs.confirmDelete";
|
||||||
|
if (!confirm(t(key, { name: o.name }))) return;
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
await request(`/api/orgs/${o.id}`, { method: "DELETE" });
|
await request(`/api/orgs/${o.id}`, { method: "DELETE" });
|
||||||
|
if (mine) await loadMe(); // now org-less and demoted to user
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// The server refuses (409) while the org still has members.
|
// The server refuses (409) while the org still has other members.
|
||||||
error.value = e.message;
|
error.value = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
@@ -78,10 +99,14 @@ async function remove(o) {
|
|||||||
<div class="dh-card overflow-hidden">
|
<div class="dh-card overflow-hidden">
|
||||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("orgs.title") }}</div>
|
<div class="text-base font-bold tracking-[-0.02em] text-strong">
|
||||||
<p class="mt-0.5 text-xs text-muted">{{ t("orgs.subtitle") }}</p>
|
{{ isSuperadmin ? t("orgs.title") : t("common.organization") }}
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-xs text-muted">
|
||||||
|
{{ isSuperadmin ? t("orgs.subtitle") : myOrg ? t("orgs.subtitleOwn") : t("orgs.subtitleNone") }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="isSuperadmin" class="dh-btn" @click="startNew">{{ t("orgs.newOrg") }}</button>
|
<button v-if="canCreate" class="dh-btn" @click="startNew">{{ t("orgs.newOrg") }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
|
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
|
||||||
@@ -98,7 +123,7 @@ async function remove(o) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!orgs.length" class="px-5 py-6 text-center text-sm text-muted">
|
<p v-if="!orgs.length" class="px-5 py-6 text-center text-sm text-muted">
|
||||||
{{ t("orgs.empty") }}
|
{{ canCreate && !isSuperadmin ? t("orgs.createHint") : t("orgs.empty") }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<table v-else class="w-full text-left text-sm">
|
<table v-else class="w-full text-left text-sm">
|
||||||
@@ -114,7 +139,7 @@ async function remove(o) {
|
|||||||
<td class="px-5 py-2.5 font-medium text-strong">{{ o.name }}</td>
|
<td class="px-5 py-2.5 font-medium text-strong">{{ o.name }}</td>
|
||||||
<td class="data px-5 py-2.5 text-xs text-muted">{{ o.id }}</td>
|
<td class="data px-5 py-2.5 text-xs text-muted">{{ o.id }}</td>
|
||||||
<td class="px-5 py-2.5 text-right whitespace-nowrap">
|
<td class="px-5 py-2.5 text-right whitespace-nowrap">
|
||||||
<template v-if="isSuperadmin">
|
<template v-if="canManage(o)">
|
||||||
<button class="dh-btn-ghost" @click="startEdit(o)">{{ t("common.rename") }}</button>
|
<button class="dh-btn-ghost" @click="startEdit(o)">{{ t("common.rename") }}</button>
|
||||||
<button class="dh-btn-danger ml-1.5" :disabled="busy" @click="remove(o)">
|
<button class="dh-btn-danger ml-1.5" :disabled="busy" @click="remove(o)">
|
||||||
{{ t("common.delete") }}
|
{{ t("common.delete") }}
|
||||||
|
|||||||
@@ -73,11 +73,15 @@
|
|||||||
"orgs": {
|
"orgs": {
|
||||||
"title": "Organisationer",
|
"title": "Organisationer",
|
||||||
"subtitle": "Enheder, som brugere tilhører",
|
"subtitle": "Enheder, som brugere tilhører",
|
||||||
|
"subtitleOwn": "Din organisation",
|
||||||
|
"subtitleNone": "Opret en for at administrere dit eget team",
|
||||||
"newOrg": "Ny organisation",
|
"newOrg": "Ny organisation",
|
||||||
"namePlaceholder": "Acme Fleet",
|
"namePlaceholder": "Acme Fleet",
|
||||||
"empty": "Ingen organisationer endnu.",
|
"empty": "Ingen organisationer endnu.",
|
||||||
|
"createHint": "Du har endnu ingen organisation. Opret en for at blive dens administrator.",
|
||||||
"colId": "ID",
|
"colId": "ID",
|
||||||
"confirmDelete": "Slet organisationen \"{name}\"?"
|
"confirmDelete": "Slet organisationen \"{name}\"?",
|
||||||
|
"confirmDeleteOwn": "Slet din organisation „{name}“? Du fjernes fra den og bliver en almindelig bruger."
|
||||||
},
|
},
|
||||||
|
|
||||||
"pocketbase": {
|
"pocketbase": {
|
||||||
|
|||||||
@@ -73,11 +73,15 @@
|
|||||||
"orgs": {
|
"orgs": {
|
||||||
"title": "Organizations",
|
"title": "Organizations",
|
||||||
"subtitle": "Tenants users belong to",
|
"subtitle": "Tenants users belong to",
|
||||||
|
"subtitleOwn": "Your organization",
|
||||||
|
"subtitleNone": "Create one to manage your own team",
|
||||||
"newOrg": "New organization",
|
"newOrg": "New organization",
|
||||||
"namePlaceholder": "Acme Fleet",
|
"namePlaceholder": "Acme Fleet",
|
||||||
"empty": "No organizations yet.",
|
"empty": "No organizations yet.",
|
||||||
|
"createHint": "You have no organization yet. Create one to become its admin.",
|
||||||
"colId": "ID",
|
"colId": "ID",
|
||||||
"confirmDelete": "Delete the organization \"{name}\"?"
|
"confirmDelete": "Delete the organization \"{name}\"?",
|
||||||
|
"confirmDeleteOwn": "Delete your organization \"{name}\"? You will be removed from it and become a regular user."
|
||||||
},
|
},
|
||||||
|
|
||||||
"pocketbase": {
|
"pocketbase": {
|
||||||
|
|||||||
@@ -73,11 +73,15 @@
|
|||||||
"orgs": {
|
"orgs": {
|
||||||
"title": "Organizacje",
|
"title": "Organizacje",
|
||||||
"subtitle": "Podmioty, do których należą użytkownicy",
|
"subtitle": "Podmioty, do których należą użytkownicy",
|
||||||
|
"subtitleOwn": "Twoja organizacja",
|
||||||
|
"subtitleNone": "Utwórz ją, aby zarządzać własnym zespołem",
|
||||||
"newOrg": "Nowa organizacja",
|
"newOrg": "Nowa organizacja",
|
||||||
"namePlaceholder": "Acme Fleet",
|
"namePlaceholder": "Acme Fleet",
|
||||||
"empty": "Brak organizacji.",
|
"empty": "Brak organizacji.",
|
||||||
|
"createHint": "Nie masz jeszcze organizacji. Utwórz ją, aby zostać jej administratorem.",
|
||||||
"colId": "ID",
|
"colId": "ID",
|
||||||
"confirmDelete": "Usunąć organizację „{name}”?"
|
"confirmDelete": "Usunąć organizację „{name}”?",
|
||||||
|
"confirmDeleteOwn": "Usunąć Twoją organizację „{name}”? Zostaniesz z niej usunięty i staniesz się zwykłym użytkownikiem."
|
||||||
},
|
},
|
||||||
|
|
||||||
"pocketbase": {
|
"pocketbase": {
|
||||||
|
|||||||
+4
-3
@@ -58,9 +58,10 @@ navigation bar** — Garage, Charging, Settings, and Users for admins — in an
|
|||||||
- **Settings** — account (name / email verification / password), appearance
|
- **Settings** — account (name / email verification / password), appearance
|
||||||
(theme + dark mode, **language**, **region**, date format, **currency**, font
|
(theme + dark mode, **language**, **region**, date format, **currency**, font
|
||||||
size), profile (avatar via `image_picker`, bio), **integrations** (Toyota,
|
size), profile (avatar via `image_picker`, bio), **integrations** (Toyota,
|
||||||
Anker Solix), **Security** (biometric toggle), and the account-deletion state
|
Anker Solix), **Security** (biometric toggle), **Organization** (create your
|
||||||
machine. Auth relays PocketBase's own stateless tokens, so there is no
|
own — which makes you its admin — or rename/delete the one you administer), and
|
||||||
per-device session list to show or revoke.
|
the account-deletion state machine. Auth relays PocketBase's own stateless
|
||||||
|
tokens, so there is no per-device session list to show or revoke.
|
||||||
- **Users (admin)** — user management tab (list / create / role / reset password
|
- **Users (admin)** — user management tab (list / create / role / reset password
|
||||||
/ delete), shown only for the admin role.
|
/ delete), shown only for the admin role.
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,24 @@
|
|||||||
"confirmPassword": "Bekræft din adgangskode",
|
"confirmPassword": "Bekræft din adgangskode",
|
||||||
"password": "Adgangskode"
|
"password": "Adgangskode"
|
||||||
},
|
},
|
||||||
|
"org": {
|
||||||
|
"title": "Organisation",
|
||||||
|
"titleAll": "Organisationer",
|
||||||
|
"subtitleOwn": "Den organisation du administrerer",
|
||||||
|
"subtitleNone": "Opret en for at administrere dit eget team",
|
||||||
|
"subtitleAll": "Enheder, som brugere tilhører",
|
||||||
|
"nameLabel": "Organisationsnavn",
|
||||||
|
"namePlaceholder": "Acme Fleet",
|
||||||
|
"newOrg": "Ny organisation",
|
||||||
|
"create": "Opret organisation",
|
||||||
|
"createHint": "Du bliver dens administrator og kan derefter tilføje og administrere brugere.",
|
||||||
|
"rename": "Omdøb",
|
||||||
|
"delete": "Slet",
|
||||||
|
"deleteTitle": "Slet organisation?",
|
||||||
|
"empty": "Ingen organisationer endnu.",
|
||||||
|
"confirmDelete": "Slet organisationen „{name}“? Dette kan ikke fortrydes.",
|
||||||
|
"confirmDeleteOwn": "Slet din organisation „{name}“? Du fjernes fra den og bliver en almindelig bruger. Dette kan ikke fortrydes."
|
||||||
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"title": "Privatliv og sikkerhed",
|
"title": "Privatliv og sikkerhed",
|
||||||
"body": "Tofaktorgodkendelse er ikke tilgængelig endnu. Sessioner bygger på tokens udstedt af serveren, som udløber af sig selv, så at logge ud afslutter kun sessionen på denne enhed. For at logge alle enheder ud skal du skifte din adgangskode ovenfor.",
|
"body": "Tofaktorgodkendelse er ikke tilgængelig endnu. Sessioner bygger på tokens udstedt af serveren, som udløber af sig selv, så at logge ud afslutter kun sessionen på denne enhed. For at logge alle enheder ud skal du skifte din adgangskode ovenfor.",
|
||||||
|
|||||||
@@ -264,6 +264,24 @@
|
|||||||
"password": "Password"
|
"password": "Password"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"org": {
|
||||||
|
"title": "Organization",
|
||||||
|
"titleAll": "Organizations",
|
||||||
|
"subtitleOwn": "The organization you administer",
|
||||||
|
"subtitleNone": "Create one to manage your own team",
|
||||||
|
"subtitleAll": "Tenants users belong to",
|
||||||
|
"nameLabel": "Organization name",
|
||||||
|
"namePlaceholder": "Acme Fleet",
|
||||||
|
"newOrg": "New organization",
|
||||||
|
"create": "Create organization",
|
||||||
|
"createHint": "You'll become its admin and can then add and manage users.",
|
||||||
|
"rename": "Rename",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteTitle": "Delete organization?",
|
||||||
|
"empty": "No organizations yet.",
|
||||||
|
"confirmDelete": "Delete the organization “{name}”? This cannot be undone.",
|
||||||
|
"confirmDeleteOwn": "Delete your organization “{name}”? You'll be removed from it and become a regular user. This cannot be undone."
|
||||||
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"title": "Privacy & security",
|
"title": "Privacy & security",
|
||||||
"body": "Two-factor authentication isn't available yet. Sessions are held as server-issued tokens that expire on their own, so signing out ends this device's session only. To lock out every device, change your password above.",
|
"body": "Two-factor authentication isn't available yet. Sessions are held as server-issued tokens that expire on their own, so signing out ends this device's session only. To lock out every device, change your password above.",
|
||||||
|
|||||||
@@ -181,6 +181,24 @@
|
|||||||
"confirmPassword": "Potwierdź hasło",
|
"confirmPassword": "Potwierdź hasło",
|
||||||
"password": "Hasło"
|
"password": "Hasło"
|
||||||
},
|
},
|
||||||
|
"org": {
|
||||||
|
"title": "Organizacja",
|
||||||
|
"titleAll": "Organizacje",
|
||||||
|
"subtitleOwn": "Organizacja, którą administrujesz",
|
||||||
|
"subtitleNone": "Utwórz ją, aby zarządzać własnym zespołem",
|
||||||
|
"subtitleAll": "Podmioty, do których należą użytkownicy",
|
||||||
|
"nameLabel": "Nazwa organizacji",
|
||||||
|
"namePlaceholder": "Acme Fleet",
|
||||||
|
"newOrg": "Nowa organizacja",
|
||||||
|
"create": "Utwórz organizację",
|
||||||
|
"createHint": "Zostaniesz jej administratorem i będziesz móc dodawać użytkowników oraz nimi zarządzać.",
|
||||||
|
"rename": "Zmień nazwę",
|
||||||
|
"delete": "Usuń",
|
||||||
|
"deleteTitle": "Usunąć organizację?",
|
||||||
|
"empty": "Brak organizacji.",
|
||||||
|
"confirmDelete": "Usunąć organizację „{name}”? Tego nie można cofnąć.",
|
||||||
|
"confirmDeleteOwn": "Usunąć Twoją organizację „{name}”? Zostaniesz z niej usunięty i staniesz się zwykłym użytkownikiem. Tego nie można cofnąć."
|
||||||
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"title": "Prywatność i bezpieczeństwo",
|
"title": "Prywatność i bezpieczeństwo",
|
||||||
"body": "Uwierzytelnianie dwuskładnikowe nie jest jeszcze dostępne. Sesje opierają się na tokenach wydawanych przez serwer, które wygasają samoczynnie, więc wylogowanie kończy tylko sesję na tym urządzeniu. Aby wylogować wszystkie urządzenia, zmień hasło powyżej.",
|
"body": "Uwierzytelnianie dwuskładnikowe nie jest jeszcze dostępne. Sesje opierają się na tokenach wydawanych przez serwer, które wygasają samoczynnie, więc wylogowanie kończy tylko sesję na tym urządzeniu. Aby wylogować wszystkie urządzenia, zmień hasło powyżej.",
|
||||||
|
|||||||
@@ -178,6 +178,29 @@ class ApiClient {
|
|||||||
|
|
||||||
Future<void> deleteUser(String id) => _send("DELETE", "/users/$id");
|
Future<void> deleteUser(String id) => _send("DELETE", "/users/$id");
|
||||||
|
|
||||||
|
// --- organizations ---
|
||||||
|
// Listing is manager-only (an admin sees just their own org), but creating is
|
||||||
|
// open to any user who has none — the creator becomes that org's admin in the
|
||||||
|
// same request. Renames and deletes are scoped to the caller's own org unless
|
||||||
|
// they are a superadmin. Responses are enveloped ({organizations}/{organization}).
|
||||||
|
Future<List<Organization>> listOrgs() async {
|
||||||
|
final data = await _send("GET", "/orgs");
|
||||||
|
final items = (data["organizations"] ?? []) as List;
|
||||||
|
return items.map((e) => Organization.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Organization> createOrg(String name) async {
|
||||||
|
final data = await _send("POST", "/orgs", body: {"name": name});
|
||||||
|
return Organization.fromJson(Map<String, dynamic>.from(data["organization"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Organization> renameOrg(String id, String name) async {
|
||||||
|
final data = await _send("PATCH", "/orgs/$id", body: {"name": name});
|
||||||
|
return Organization.fromJson(Map<String, dynamic>.from(data["organization"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteOrg(String id) => _send("DELETE", "/orgs/$id");
|
||||||
|
|
||||||
// --- service records ---
|
// --- service records ---
|
||||||
Future<List<ServiceRecord>> listCarServices(String carId) async {
|
Future<List<ServiceRecord>> listCarServices(String carId) async {
|
||||||
final data = await _send("GET", "/cars/$carId/service-records") as List;
|
final data = await _send("GET", "/cars/$carId/service-records") as List;
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ class AuthService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adopts the role from a freshly fetched profile. A session's role can change
|
||||||
|
/// under it — creating an organization promotes the creator to that org's admin
|
||||||
|
/// — and the nav gates the Users tab on the cached copy, so it has to catch up
|
||||||
|
/// without requiring a re-login. A no-op when the role is unchanged.
|
||||||
|
Future<void> adoptRole(UserProfile profile) async {
|
||||||
|
final u = user;
|
||||||
|
if (u == null || profile.role == u.role) return;
|
||||||
|
user = AuthUser(id: u.id, email: u.email, name: u.name, role: profile.role);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_userKey, jsonEncode(user!.toJson()));
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
api.token = null;
|
api.token = null;
|
||||||
user = null;
|
user = null;
|
||||||
|
|||||||
@@ -663,6 +663,21 @@ class AdminUser {
|
|||||||
bool get isSuperadmin => role == "superadmin";
|
bool get isSuperadmin => role == "superadmin";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A tenant users belong to, as returned by the organization endpoints.
|
||||||
|
class Organization {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String created;
|
||||||
|
|
||||||
|
Organization({required this.id, required this.name, this.created = ""});
|
||||||
|
|
||||||
|
factory Organization.fromJson(Map<String, dynamic> j) => Organization(
|
||||||
|
id: _asStr(j["id"]),
|
||||||
|
name: _asStr(j["name"]),
|
||||||
|
created: _asStr(j["created"]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The full authenticated profile (Settings panel), mirroring /api/me.
|
/// The full authenticated profile (Settings panel), mirroring /api/me.
|
||||||
class UserProfile {
|
class UserProfile {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -677,6 +692,8 @@ class UserProfile {
|
|||||||
final String currency; // ISO 4217 code, e.g. "EUR"
|
final String currency; // ISO 4217 code, e.g. "EUR"
|
||||||
final String fontSize; // small | medium | large
|
final String fontSize; // small | medium | large
|
||||||
final String role; // user | admin
|
final String role; // user | admin
|
||||||
|
final String organization; // org record id ("" = belongs to no organization)
|
||||||
|
final String organizationName; // resolved name ("" when unset/unresolvable)
|
||||||
final DateTime? deletionRequestedAt;
|
final DateTime? deletionRequestedAt;
|
||||||
|
|
||||||
UserProfile({
|
UserProfile({
|
||||||
@@ -692,6 +709,8 @@ class UserProfile {
|
|||||||
this.currency = "USD",
|
this.currency = "USD",
|
||||||
required this.fontSize,
|
required this.fontSize,
|
||||||
required this.role,
|
required this.role,
|
||||||
|
this.organization = "",
|
||||||
|
this.organizationName = "",
|
||||||
required this.deletionRequestedAt,
|
required this.deletionRequestedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -708,6 +727,8 @@ class UserProfile {
|
|||||||
currency: j["currency"] == null ? "USD" : _asStr(j["currency"]),
|
currency: j["currency"] == null ? "USD" : _asStr(j["currency"]),
|
||||||
fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]),
|
fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]),
|
||||||
role: j["role"] == null ? "user" : _asStr(j["role"]),
|
role: j["role"] == null ? "user" : _asStr(j["role"]),
|
||||||
|
organization: _asStr(j["organization"]),
|
||||||
|
organizationName: _asStr(j["organizationName"]),
|
||||||
deletionRequestedAt: j["deletionRequestedAt"] == null
|
deletionRequestedAt: j["deletionRequestedAt"] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(),
|
: DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(),
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
try {
|
try {
|
||||||
final p = await apiClient.getMe();
|
final p = await apiClient.getMe();
|
||||||
appSettings.applyFromProfile(p);
|
appSettings.applyFromProfile(p);
|
||||||
|
// The profile is the authority on the role; creating or deleting an
|
||||||
|
// organization changes it, so keep the session's cached copy (which gates
|
||||||
|
// the Users tab) in step.
|
||||||
|
await authService.adoptRole(p);
|
||||||
Uint8List? avatar;
|
Uint8List? avatar;
|
||||||
if (p.hasAvatar) {
|
if (p.hasAvatar) {
|
||||||
final bytes = await apiClient.getAvatarBytes();
|
final bytes = await apiClient.getAvatarBytes();
|
||||||
@@ -128,6 +132,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_SecuritySection(email: _profile!.email, snack: _snack),
|
_SecuritySection(email: _profile!.email, snack: _snack),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
_OrganizationSection(profile: _profile!, onChanged: _load, snack: _snack),
|
||||||
|
const SizedBox(height: 12),
|
||||||
const _PrivacySection(),
|
const _PrivacySection(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_DangerSection(profile: _profile!, onChanged: _load, snack: _snack),
|
_DangerSection(profile: _profile!, onChanged: _load, snack: _snack),
|
||||||
@@ -858,6 +864,252 @@ class _SecuritySectionState extends State<_SecuritySection> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Organization -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// Organization membership, adapting to who is looking:
|
||||||
|
/// - a user with no organization gets a "create your own" field, and becomes
|
||||||
|
/// the admin of what they create;
|
||||||
|
/// - an admin sees their own org with rename + delete (deleting it detaches
|
||||||
|
/// them and drops them back to a plain user);
|
||||||
|
/// - a superadmin sees every org and can create, rename and delete any of them.
|
||||||
|
/// The API Server enforces all of this; this card only mirrors it.
|
||||||
|
class _OrganizationSection extends StatefulWidget {
|
||||||
|
final UserProfile profile;
|
||||||
|
final Future<void> Function() onChanged;
|
||||||
|
final void Function(String) snack;
|
||||||
|
const _OrganizationSection({
|
||||||
|
required this.profile,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.snack,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
State<_OrganizationSection> createState() => _OrganizationSectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrganizationSectionState extends State<_OrganizationSection> {
|
||||||
|
final _createName = TextEditingController();
|
||||||
|
List<Organization> _orgs = [];
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
bool get _isSuperadmin => widget.profile.isSuperadmin;
|
||||||
|
bool get _isManager => widget.profile.isAdmin;
|
||||||
|
String get _myOrg => widget.profile.organization;
|
||||||
|
// Anyone who is not a superadmin and has no org yet can stand one up.
|
||||||
|
bool get _showCreateOwn => !_isSuperadmin && _myOrg.isEmpty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_createName.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
// Listing is manager-only; an org-less user just gets the create field.
|
||||||
|
if (!_isManager) {
|
||||||
|
if (mounted) setState(() => _orgs = []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final list = await apiClient.listOrgs();
|
||||||
|
if (mounted) setState(() => _orgs = list);
|
||||||
|
} catch (e) {
|
||||||
|
widget.snack("$e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creating an org as a non-superadmin promotes the caller to its admin, so the
|
||||||
|
/// profile is reloaded to pick up the new role + membership.
|
||||||
|
Future<void> _create(String name) async {
|
||||||
|
if (name.trim().isEmpty) return;
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
await apiClient.createOrg(name.trim());
|
||||||
|
_createName.clear();
|
||||||
|
if (!_isSuperadmin) await widget.onChanged();
|
||||||
|
await _load();
|
||||||
|
} catch (e) {
|
||||||
|
widget.snack("$e");
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _promptCreate() async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(t("settings.org.newOrg")),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: t("settings.org.nameLabel"),
|
||||||
|
hintText: t("settings.org.namePlaceholder"),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(t("settings.org.create"))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true) await _create(controller.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _promptRename(Organization o) async {
|
||||||
|
final controller = TextEditingController(text: o.name);
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(t("settings.org.rename")),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: t("settings.org.nameLabel"),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(t("common.save"))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true || controller.text.trim().isEmpty) return;
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
await apiClient.renameOrg(o.id, controller.text.trim());
|
||||||
|
await _load();
|
||||||
|
} catch (e) {
|
||||||
|
widget.snack("$e");
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _promptDelete(Organization o) async {
|
||||||
|
// Deleting your own org detaches you from it and demotes you to a plain user.
|
||||||
|
final mine = !_isSuperadmin && o.id == _myOrg;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(t("settings.org.deleteTitle")),
|
||||||
|
content: Text(mine
|
||||||
|
? t("settings.org.confirmDeleteOwn", params: {"name": o.name})
|
||||||
|
: t("settings.org.confirmDelete", params: {"name": o.name})),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t("common.cancel"))),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: DriverVault.danger),
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(t("settings.org.delete")),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
await apiClient.deleteOrg(o.id);
|
||||||
|
if (mine) await widget.onChanged(); // now org-less and demoted to user
|
||||||
|
await _load();
|
||||||
|
} catch (e) {
|
||||||
|
// The server refuses (409) while the org still has other members.
|
||||||
|
widget.snack("$e");
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final muted = DriverVault.muted(context);
|
||||||
|
return _Card(
|
||||||
|
title: _isSuperadmin ? t("settings.org.titleAll") : t("settings.org.title"),
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_isSuperadmin
|
||||||
|
? t("settings.org.subtitleAll")
|
||||||
|
: _showCreateOwn
|
||||||
|
? t("settings.org.subtitleNone")
|
||||||
|
: t("settings.org.subtitleOwn"),
|
||||||
|
style: TextStyle(fontSize: 12, color: muted),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_showCreateOwn) ...[
|
||||||
|
TextField(
|
||||||
|
controller: _createName,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: t("settings.org.nameLabel"),
|
||||||
|
hintText: t("settings.org.namePlaceholder"),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: _busy ? null : _create,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _busy ? null : () => _create(_createName.text),
|
||||||
|
child: Text(_busy ? t("common.saving") : t("settings.org.create")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(t("settings.org.createHint"), style: TextStyle(fontSize: 12, color: muted)),
|
||||||
|
] else ...[
|
||||||
|
if (_isSuperadmin)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _busy ? null : _promptCreate,
|
||||||
|
child: Text(t("settings.org.newOrg")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_orgs.isEmpty)
|
||||||
|
Text(t("settings.org.empty"), style: TextStyle(fontSize: 13, color: muted))
|
||||||
|
else
|
||||||
|
for (final o in _orgs)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(o.name, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
Text(o.id, style: TextStyle(fontSize: 11, color: muted)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: t("settings.org.rename"),
|
||||||
|
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||||
|
onPressed: _busy ? null : () => _promptRename(o),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: t("settings.org.delete"),
|
||||||
|
icon: const Icon(Icons.delete_outline, size: 20, color: DriverVault.danger),
|
||||||
|
onPressed: _busy ? null : () => _promptDelete(o),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Privacy & security -----------------------------------------------------
|
// --- Privacy & security -----------------------------------------------------
|
||||||
|
|
||||||
/// Sessions are PocketBase's own stateless tokens, so there is no per-device
|
/// Sessions are PocketBase's own stateless tokens, so there is no per-device
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export/import).
|
|||||||
export/import, and an account-deletion state machine.
|
export/import, and an account-deletion state machine.
|
||||||
- **Organizations & roles** — multi-tenant `user` / `admin` / `superadmin`
|
- **Organizations & roles** — multi-tenant `user` / `admin` / `superadmin`
|
||||||
roles; admins manage users within their own organization, superadmins span all.
|
roles; admins manage users within their own organization, superadmins span all.
|
||||||
|
Any user without an organization can create one and becomes its admin.
|
||||||
- **Per-user ownership & sharing** — each car has an owner and can be shared with
|
- **Per-user ownership & sharing** — each car has an owner and can be shared with
|
||||||
other users as read or write; the UI mirrors the server's access checks.
|
other users as read or write; the UI mirrors the server's access checks.
|
||||||
- **Integrations** — per-user connectors under a superadmin → org-admin → user
|
- **Integrations** — per-user connectors under a superadmin → org-admin → user
|
||||||
@@ -86,7 +87,9 @@ against PocketBase on each call, so a role change or a deletion takes effect
|
|||||||
immediately. Tokens are stateless, so there is no per-device session list;
|
immediately. Tokens are stateless, so there is no per-device session list;
|
||||||
changing an account's password rotates its token key and invalidates every token
|
changing an account's password rotates its token key and invalidates every token
|
||||||
already issued. Access to cars/records is gated by per-user ownership and shares;
|
already issued. Access to cars/records is gated by per-user ownership and shares;
|
||||||
user management requires the admin or superadmin role.
|
user management requires the admin or superadmin role. Creating an organization
|
||||||
|
is the one management action open to a plain user — it promotes them to admin of
|
||||||
|
the organization they just created.
|
||||||
|
|
||||||
## Domain (from `Car Service.xlsx`)
|
## Domain (from `Car Service.xlsx`)
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -32,7 +32,8 @@ web/ Vue 3 + Vite + Tailwind v4 source
|
|||||||
App.vue layout shell + nav (Charging + Admin links when relevant)
|
App.vue layout shell + nav (Charging + Admin links when relevant)
|
||||||
components/ Modal, AttachmentField, CarFormModal, ServiceFormModal,
|
components/ Modal, AttachmentField, CarFormModal, ServiceFormModal,
|
||||||
TechnicalCheckFormModal, MaintenanceFormModal, FuelFormModal,
|
TechnicalCheckFormModal, MaintenanceFormModal, FuelFormModal,
|
||||||
DocumentFormModal, ReminderFormModal, PartFormModal, ShareModal, Logo
|
DocumentFormModal, ReminderFormModal, PartFormModal, ShareModal,
|
||||||
|
OrgManager, Logo
|
||||||
views/ Login, Dashboard, CarDetail, Charging, Settings, AdminUsers
|
views/ Login, Dashboard, CarDetail, Charging, Settings, AdminUsers
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -117,8 +118,9 @@ Config (`server/.env`, copy from `.env.example`):
|
|||||||
controls driven by the API Server's OCPP Central System.
|
controls driven by the API Server's OCPP Central System.
|
||||||
- **Settings** — split into tabs: account (name / email verification / password),
|
- **Settings** — split into tabs: account (name / email verification / password),
|
||||||
appearance (theme light/dark/system, locale, date format, currency, font size),
|
appearance (theme light/dark/system, locale, date format, currency, font size),
|
||||||
profile (avatar, bio), **integrations** (Toyota, Anker Solix), data
|
profile (avatar, bio), **integrations** (Toyota, Anker Solix), **organization**
|
||||||
**export/import**, and the account-deletion state machine.
|
(create your own — which makes you its admin — or rename/delete the one you
|
||||||
|
administer), data **export/import**, and the account-deletion state machine.
|
||||||
- **Admin** — `/admin` user management (list / create / role / reset password /
|
- **Admin** — `/admin` user management (list / create / role / reset password /
|
||||||
delete), gated by the admin role via a router guard + nav link.
|
delete), gated by the admin role via a router guard + nav link.
|
||||||
- **Theming** — light/dark/system app-wide (Tailwind v4 class strategy); `prefs.js`
|
- **Theming** — light/dark/system app-wide (Tailwind v4 class strategy); `prefs.js`
|
||||||
|
|||||||
@@ -208,6 +208,17 @@ export const api = {
|
|||||||
request(`/users/${id}`, { method: "PATCH", body: JSON.stringify({ password }) }).then((r) => r.user),
|
request(`/users/${id}`, { method: "PATCH", body: JSON.stringify({ password }) }).then((r) => r.user),
|
||||||
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
|
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Organizations. Listing is manager-only (an admin sees just their own org),
|
||||||
|
// but creating is open to any user without one — the creator becomes its
|
||||||
|
// admin. Renaming and deleting are scoped to the caller's own org unless they
|
||||||
|
// are a superadmin.
|
||||||
|
listOrgs: () => request("/orgs").then((r) => r.organizations),
|
||||||
|
createOrg: (name) =>
|
||||||
|
request("/orgs", { method: "POST", body: JSON.stringify({ name }) }).then((r) => r.organization),
|
||||||
|
updateOrg: (id, name) =>
|
||||||
|
request(`/orgs/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }).then((r) => r.organization),
|
||||||
|
deleteOrg: (id) => request(`/orgs/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
// Settings — account/profile/appearance
|
// Settings — account/profile/appearance
|
||||||
getMe: () => request("/me"),
|
getMe: () => request("/me"),
|
||||||
updateMe: (body) => request("/me", { method: "PATCH", body: JSON.stringify(body) }),
|
updateMe: (body) => request("/me", { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { state, refreshProfile } from "../auth";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
// Organization management, adapting to who is looking:
|
||||||
|
// - a user with no organization gets a "create your own" form, and becomes the
|
||||||
|
// admin of what they create;
|
||||||
|
// - an admin sees their own org with Rename + Delete (deleting it detaches them
|
||||||
|
// and drops them back to a plain user);
|
||||||
|
// - a superadmin sees every org and can create, rename and delete any of them.
|
||||||
|
// The API Server enforces all of this; this component only mirrors it.
|
||||||
|
const role = computed(() => state.profile?.role || state.user?.role || "user");
|
||||||
|
const isSuperadmin = computed(() => role.value === "superadmin");
|
||||||
|
const isManager = computed(() => ["admin", "superadmin"].includes(role.value));
|
||||||
|
const myOrg = computed(() => state.profile?.organization || "");
|
||||||
|
// Anyone who is not a superadmin and has no org yet can stand one up.
|
||||||
|
const showCreateOwn = computed(() => !isSuperadmin.value && !myOrg.value);
|
||||||
|
|
||||||
|
const orgs = ref([]);
|
||||||
|
const error = ref("");
|
||||||
|
const busy = ref(false);
|
||||||
|
const editing = ref(null); // org id, or "new"
|
||||||
|
const draftName = ref("");
|
||||||
|
const createName = ref(""); // for the org-less "create your own" form
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
// Listing is manager-only; an org-less user just gets the create form.
|
||||||
|
if (!isManager.value) {
|
||||||
|
orgs.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
orgs.value = (await api.listOrgs()) || [];
|
||||||
|
error.value = "";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(load);
|
||||||
|
|
||||||
|
// An org-less user creates their first org and is promoted to its admin.
|
||||||
|
async function createOwnOrg() {
|
||||||
|
const name = createName.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
busy.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await api.createOrg(name);
|
||||||
|
createName.value = "";
|
||||||
|
await refreshProfile(); // role -> admin, organization now set
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message;
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNew() {
|
||||||
|
editing.value = "new";
|
||||||
|
draftName.value = "";
|
||||||
|
}
|
||||||
|
function startEdit(o) {
|
||||||
|
editing.value = o.id;
|
||||||
|
draftName.value = o.name;
|
||||||
|
}
|
||||||
|
function cancel() {
|
||||||
|
editing.value = null;
|
||||||
|
error.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const name = draftName.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
busy.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
if (editing.value === "new") {
|
||||||
|
await api.createOrg(name);
|
||||||
|
if (!isSuperadmin.value) await refreshProfile();
|
||||||
|
} else {
|
||||||
|
await api.updateOrg(editing.value, name);
|
||||||
|
}
|
||||||
|
editing.value = null;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message;
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(o) {
|
||||||
|
const mine = !isSuperadmin.value && o.id === myOrg.value;
|
||||||
|
const msg = mine
|
||||||
|
? t("settings.org.confirmDeleteOwn", { name: o.name })
|
||||||
|
: t("settings.org.confirmDelete", { name: o.name });
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
busy.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await api.deleteOrg(o.id);
|
||||||
|
if (mine) await refreshProfile(); // now org-less and demoted to user
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
// The server refuses (409) while the org still has other members.
|
||||||
|
error.value = e.message;
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="dh-card p-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">
|
||||||
|
{{ isSuperadmin ? t("settings.org.titleAll") : t("settings.org.title") }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-0.5 text-xs text-muted">
|
||||||
|
{{
|
||||||
|
isSuperadmin
|
||||||
|
? t("settings.org.subtitleAll")
|
||||||
|
: showCreateOwn
|
||||||
|
? t("settings.org.subtitleNone")
|
||||||
|
: t("settings.org.subtitleOwn")
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button v-if="isSuperadmin" class="dh-btn dh-btn-ghost !px-3 !py-1.5 shrink-0" @click="startNew">
|
||||||
|
{{ t("settings.org.newOrg") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Org-less user: create your own organization and become its admin -->
|
||||||
|
<div v-if="showCreateOwn">
|
||||||
|
<label class="dh-label">{{ t("settings.org.nameLabel") }}</label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="createName"
|
||||||
|
class="dh-input max-w-sm"
|
||||||
|
:placeholder="t('settings.org.namePlaceholder')"
|
||||||
|
autocomplete="off"
|
||||||
|
@keyup.enter="createOwnOrg"
|
||||||
|
/>
|
||||||
|
<button class="dh-btn shrink-0" :disabled="busy || !createName.trim()" @click="createOwnOrg">
|
||||||
|
{{ t("settings.org.create") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-muted">{{ t("settings.org.createHint") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Managers: manage the organization(s) they own -->
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="editing" class="mb-4 rounded-control bg-sunken p-4">
|
||||||
|
<label class="dh-label">{{ t("settings.org.nameLabel") }}</label>
|
||||||
|
<input
|
||||||
|
v-model="draftName"
|
||||||
|
class="dh-input max-w-sm"
|
||||||
|
:placeholder="t('settings.org.namePlaceholder')"
|
||||||
|
autocomplete="off"
|
||||||
|
@keyup.enter="save"
|
||||||
|
/>
|
||||||
|
<div class="mt-3 flex items-center gap-2">
|
||||||
|
<button class="dh-btn" :disabled="busy || !draftName.trim()" @click="save">
|
||||||
|
{{ editing === "new" ? t("settings.org.create") : t("common.save") }}
|
||||||
|
</button>
|
||||||
|
<button class="dh-btn dh-btn-ghost" :disabled="busy" @click="cancel">{{ t("common.cancel") }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!orgs.length" class="text-sm text-muted">{{ t("settings.org.empty") }}</p>
|
||||||
|
|
||||||
|
<ul v-else class="divide-y divide-subtle">
|
||||||
|
<li v-for="o in orgs" :key="o.id" class="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-medium text-strong">{{ o.name }}</p>
|
||||||
|
<p class="data truncate text-xs text-muted">{{ o.id }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5" @click="startEdit(o)">
|
||||||
|
{{ t("settings.org.rename") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="dh-btn dh-btn-ghost !px-3 !py-1.5 !text-danger"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="remove(o)"
|
||||||
|
>
|
||||||
|
{{ t("common.delete") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"title": "Brugere",
|
"title": "Brugere",
|
||||||
"subtitleAll": "Konti på tværs af alle organisationer.",
|
"subtitleAll": "Konti på tværs af alle organisationer.",
|
||||||
"subtitleOrg": "Konti i din organisation.",
|
"subtitleOrg": "Konti i din organisation.",
|
||||||
"subtitleOrgsNote": "Organisationer tildeles i API-panelet.",
|
"subtitleOrgsNote": "Organisationer administreres under Indstillinger.",
|
||||||
"addUser": "Tilføj bruger",
|
"addUser": "Tilføj bruger",
|
||||||
"colEmail": "E-mail",
|
"colEmail": "E-mail",
|
||||||
"colName": "Navn",
|
"colName": "Navn",
|
||||||
@@ -199,6 +199,23 @@
|
|||||||
"saveBio": "Gem beskrivelse"
|
"saveBio": "Gem beskrivelse"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"org": {
|
||||||
|
"title": "Organisation",
|
||||||
|
"titleAll": "Organisationer",
|
||||||
|
"subtitleOwn": "Den organisation du administrerer",
|
||||||
|
"subtitleNone": "Opret en for at administrere dit eget team",
|
||||||
|
"subtitleAll": "Enheder, som brugere tilhører",
|
||||||
|
"nameLabel": "Organisationsnavn",
|
||||||
|
"namePlaceholder": "Acme Fleet",
|
||||||
|
"newOrg": "Ny organisation",
|
||||||
|
"create": "Opret organisation",
|
||||||
|
"createHint": "Du bliver dens administrator og kan derefter tilføje og administrere brugere.",
|
||||||
|
"rename": "Omdøb",
|
||||||
|
"empty": "Ingen organisationer endnu.",
|
||||||
|
"confirmDelete": "Slet organisationen „{name}“? Dette kan ikke fortrydes.",
|
||||||
|
"confirmDeleteOwn": "Slet din organisation „{name}“? Du fjernes fra den og bliver en almindelig bruger. Dette kan ikke fortrydes."
|
||||||
|
},
|
||||||
|
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"title": "Privatliv og sikkerhed",
|
"title": "Privatliv og sikkerhed",
|
||||||
"signOut": "Log ud",
|
"signOut": "Log ud",
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
"title": "Users",
|
"title": "Users",
|
||||||
"subtitleAll": "Accounts across every organization.",
|
"subtitleAll": "Accounts across every organization.",
|
||||||
"subtitleOrg": "Accounts in your organization.",
|
"subtitleOrg": "Accounts in your organization.",
|
||||||
"subtitleOrgsNote": "Organizations are assigned in the API panel.",
|
"subtitleOrgsNote": "Organizations are managed in Settings.",
|
||||||
"addUser": "Add user",
|
"addUser": "Add user",
|
||||||
"colEmail": "Email",
|
"colEmail": "Email",
|
||||||
"colName": "Name",
|
"colName": "Name",
|
||||||
@@ -274,6 +274,23 @@
|
|||||||
"controlDisconnected": "Not connected"
|
"controlDisconnected": "Not connected"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"org": {
|
||||||
|
"title": "Organization",
|
||||||
|
"titleAll": "Organizations",
|
||||||
|
"subtitleOwn": "The organization you administer",
|
||||||
|
"subtitleNone": "Create one to manage your own team",
|
||||||
|
"subtitleAll": "Tenants users belong to",
|
||||||
|
"nameLabel": "Organization name",
|
||||||
|
"namePlaceholder": "Acme Fleet",
|
||||||
|
"newOrg": "New organization",
|
||||||
|
"create": "Create organization",
|
||||||
|
"createHint": "You'll become its admin and can then add and manage users.",
|
||||||
|
"rename": "Rename",
|
||||||
|
"empty": "No organizations yet.",
|
||||||
|
"confirmDelete": "Delete the organization “{name}”? This cannot be undone.",
|
||||||
|
"confirmDeleteOwn": "Delete your organization “{name}”? You'll be removed from it and become a regular user. This cannot be undone."
|
||||||
|
},
|
||||||
|
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"title": "Privacy & security",
|
"title": "Privacy & security",
|
||||||
"signOut": "Sign out",
|
"signOut": "Sign out",
|
||||||
|
|||||||
@@ -112,7 +112,7 @@
|
|||||||
"title": "Użytkownicy",
|
"title": "Użytkownicy",
|
||||||
"subtitleAll": "Konta ze wszystkich organizacji.",
|
"subtitleAll": "Konta ze wszystkich organizacji.",
|
||||||
"subtitleOrg": "Konta w Twojej organizacji.",
|
"subtitleOrg": "Konta w Twojej organizacji.",
|
||||||
"subtitleOrgsNote": "Organizacje przypisuje się w panelu API.",
|
"subtitleOrgsNote": "Organizacjami zarządza się w Ustawieniach.",
|
||||||
"addUser": "Dodaj użytkownika",
|
"addUser": "Dodaj użytkownika",
|
||||||
"colEmail": "E-mail",
|
"colEmail": "E-mail",
|
||||||
"colName": "Imię i nazwisko",
|
"colName": "Imię i nazwisko",
|
||||||
@@ -203,6 +203,23 @@
|
|||||||
"saveBio": "Zapisz opis"
|
"saveBio": "Zapisz opis"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"org": {
|
||||||
|
"title": "Organizacja",
|
||||||
|
"titleAll": "Organizacje",
|
||||||
|
"subtitleOwn": "Organizacja, którą administrujesz",
|
||||||
|
"subtitleNone": "Utwórz ją, aby zarządzać własnym zespołem",
|
||||||
|
"subtitleAll": "Podmioty, do których należą użytkownicy",
|
||||||
|
"nameLabel": "Nazwa organizacji",
|
||||||
|
"namePlaceholder": "Acme Fleet",
|
||||||
|
"newOrg": "Nowa organizacja",
|
||||||
|
"create": "Utwórz organizację",
|
||||||
|
"createHint": "Zostaniesz jej administratorem i będziesz móc dodawać użytkowników oraz nimi zarządzać.",
|
||||||
|
"rename": "Zmień nazwę",
|
||||||
|
"empty": "Brak organizacji.",
|
||||||
|
"confirmDelete": "Usunąć organizację „{name}”? Tego nie można cofnąć.",
|
||||||
|
"confirmDeleteOwn": "Usunąć Twoją organizację „{name}”? Zostaniesz z niej usunięty i staniesz się zwykłym użytkownikiem. Tego nie można cofnąć."
|
||||||
|
},
|
||||||
|
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"title": "Prywatność i bezpieczeństwo",
|
"title": "Prywatność i bezpieczeństwo",
|
||||||
"signOut": "Wyloguj się",
|
"signOut": "Wyloguj się",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { state, logout, refreshProfile } from "../auth";
|
|||||||
import { prefs, applyProfilePrefs } from "../prefs";
|
import { prefs, applyProfilePrefs } from "../prefs";
|
||||||
import { formatDate, formatMoney } from "../lib/format.js";
|
import { formatDate, formatMoney } from "../lib/format.js";
|
||||||
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
|
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
|
||||||
|
import OrgManager from "../components/OrgManager.vue";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -1258,6 +1259,9 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- Personal settings (continued) -->
|
<!-- Personal settings (continued) -->
|
||||||
<div v-show="activeTab === 'personal'" class="space-y-6">
|
<div v-show="activeTab === 'personal'" class="space-y-6">
|
||||||
|
<!-- Organization: create your own (becoming its admin), or manage it -->
|
||||||
|
<OrgManager />
|
||||||
|
|
||||||
<!-- Privacy & Security -->
|
<!-- Privacy & Security -->
|
||||||
<section class="dh-card p-6">
|
<section class="dh-card p-6">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
|||||||
Reference in New Issue
Block a user