Add all missing app templates: projects, project, request, orgs, admin

This commit is contained in:
James 2026-02-28 06:48:51 -05:00
parent 32964abb7c
commit e6a68822c2
11 changed files with 1689 additions and 220 deletions

78
SPEC.md
View File

@ -881,6 +881,84 @@ CREATE INDEX idx_delegations_delegate ON delegations(delegate_id);
- Actions by delegate are logged with both `actor_id = delegate_id` and `on_behalf_of = user_id` - Actions by delegate are logged with both `actor_id = delegate_id` and `on_behalf_of = user_id`
- User can create their own delegations; admins can create delegations for their team - User can create their own delegations; admins can create delegations for their team
---
---
## 19. Organizations
Organizations represent companies/firms participating in deals. They exist at the platform level and can be linked to multiple deals.
### 19.1 Data Model
Organizations are entries with `type: "organization"`, `depth: 0`, no parent. They live at platform level.
**OrgData (packed into entry.Data):**
```go
type OrgData struct {
Name string `json:"name"`
Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"]
Role string `json:"role"` // seller | buyer | ib | advisor
Website string `json:"website,omitempty"`
Description string `json:"description,omitempty"`
ContactName string `json:"contact_name,omitempty"`
ContactEmail string `json:"contact_email,omitempty"`
}
```
A **deal_org** entry (`type: "deal_org"`, `depth: 1`, `parent = project entry_id`) links an organization into a specific deal:
```go
type DealOrgData struct {
OrgID string `json:"org_id"` // entry_id of the organization
Role string `json:"role"` // seller | buyer | ib | advisor
DomainLock bool `json:"domain_lock"` // if true, enforce domain check on invites
}
```
### 19.2 Domain Validation (ELIGIBILITY ONLY)
**CRITICAL SECURITY BOUNDARY:**
Organization domains determine **invite eligibility only**. They answer: "Is this email address allowed to be invited to this role in this deal?"
**They do NOT grant any access whatsoever.**
Example: PwC (@pwc.com) is a buyer in Project James AND a seller in Project Tanya. A PwC partner invited to Project James has **ZERO visibility** into Project Tanya, even though PwC is listed there.
**Rules:**
1. `ListProjects` / `GET /api/projects` returns ONLY projects where the actor has an explicit row in the `access` table. NEVER derive project visibility from deal_org or org membership.
2. `CheckAccess` is the single gate — it checks the `access` table, not org membership.
3. The `deal_org` entry is queried ONLY during invite creation to validate the email domain. After that, it has no effect on access.
4. `super_admin` bypasses this (as already implemented) — but no other role does.
**ValidateOrgDomain function:**
- Called during invite creation only
- Finds all `deal_org` entries for the project where role matches
- For each with `DomainLock=true`: checks if email ends with @domain for ANY domain in org.Domains
- If no match found → returns `ErrDomainMismatch`
- If `DomainLock=false` or no matching deal_orgs → passes through
### 19.3 Multi-Domain Support
Organizations can have multiple domains (e.g., post-acquisition companies). All domains are stored in the `Domains` array. Domain validation passes if the email matches **any** of the org's domains.
Empty domains are not allowed. Domains are normalized to lowercase on create/update.
### 19.4 API Endpoints
**Organizations (platform level):**
- `GET /api/orgs` — list all orgs (super_admin sees all; others see orgs linked to their deals)
- `POST /api/orgs` — create org (ib_admin or super_admin only). Domains required.
- `GET /api/orgs/{orgID}` — get org
- `PATCH /api/orgs/{orgID}` — update org
**Deal orgs (per project):**
- `GET /api/projects/{projectID}/orgs` — list orgs in this deal
- `POST /api/projects/{projectID}/orgs` — add org to deal `{"org_id":"...","role":"seller","domain_lock":true}`
- `DELETE /api/projects/{projectID}/orgs/{dealOrgID}` — remove org from deal
--- ---
*This document is the ground truth. If code disagrees with the spec, the code is wrong.* *This document is the ground truth. If code disagrees with the spec, the code is wrong.*

View File

@ -577,6 +577,10 @@ func (h *Handlers) GetAllTasks(w http.ResponseWriter, r *http.Request) {
} }
// GetAllProjects handles GET /api/projects (all projects current user has access to) // GetAllProjects handles GET /api/projects (all projects current user has access to)
//
// SECURITY: Returns ONLY projects where the actor has an explicit row in the access table.
// Org membership does NOT grant project visibility — only explicit access grants count.
// deal_org entries are for domain validation during invite creation only.
func (h *Handlers) GetAllProjects(w http.ResponseWriter, r *http.Request) { func (h *Handlers) GetAllProjects(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context()) actorID := UserIDFromContext(r.Context())
entries, err := lib.ProjectsByUser(h.DB, h.Cfg, actorID) entries, err := lib.ProjectsByUser(h.DB, h.Cfg, actorID)
@ -923,3 +927,634 @@ func generateToken() string {
rand.Read(b) rand.Read(b)
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// ---------------------------------------------------------------------------
// Organization API endpoints
// ---------------------------------------------------------------------------
// ListOrgs handles GET /api/orgs — list all organizations
// super_admin sees all; others see orgs they're members of
func (h *Handlers) ListOrgs(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
// Check if super_admin
isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)
var orgs []lib.Entry
var err error
if isSuperAdmin {
// Super admin sees all organizations
orgs, err = h.queryOrgsByType("")
} else {
// Others see orgs they have access to (via deal_org links in their projects)
orgs, err = h.queryOrgsForUser(actorID)
}
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to list organizations")
return
}
// Unpack each org's data
var result []map[string]any
for _, org := range orgs {
orgMap := h.orgToMap(&org)
result = append(result, orgMap)
}
if result == nil {
result = []map[string]any{}
}
JSONResponse(w, http.StatusOK, result)
}
func (h *Handlers) queryOrgsByType(role string) ([]lib.Entry, error) {
q := `SELECT entry_id, project_id, parent_id, type, depth,
search_key, search_key2, summary, data, stage,
assignee_id, return_to_id, origin_id,
version, deleted_at, deleted_by, key_version,
created_at, updated_at, created_by
FROM entries WHERE type = 'organization' AND deleted_at IS NULL
ORDER BY created_at DESC`
rows, err := h.DB.Conn.Query(q)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []lib.Entry
for rows.Next() {
var e lib.Entry
err := rows.Scan(
&e.EntryID, &e.ProjectID, &e.ParentID, &e.Type, &e.Depth,
&e.SearchKey, &e.SearchKey2, &e.Summary, &e.Data, &e.Stage,
&e.AssigneeID, &e.ReturnToID, &e.OriginID,
&e.Version, &e.DeletedAt, &e.DeletedBy, &e.KeyVersion,
&e.CreatedAt, &e.UpdatedAt, &e.CreatedBy,
)
if err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
func (h *Handlers) queryOrgsForUser(userID string) ([]lib.Entry, error) {
// Get all projects user has access to
rows, err := h.DB.Conn.Query(
`SELECT DISTINCT e.entry_id, e.project_id, e.parent_id, e.type, e.depth,
e.search_key, e.search_key2, e.summary, e.data, e.stage,
e.assignee_id, e.return_to_id, e.origin_id,
e.version, e.deleted_at, e.deleted_by, e.key_version,
e.created_at, e.updated_at, e.created_by
FROM entries e
WHERE e.type = 'organization' AND e.deleted_at IS NULL
AND e.entry_id IN (
SELECT DISTINCT json_extract(e2.data, '$.org_id')
FROM entries e2
JOIN access a ON a.project_id = e2.project_id
WHERE a.user_id = ? AND a.revoked_at IS NULL
AND e2.type = 'deal_org' AND e2.deleted_at IS NULL
)
ORDER BY e.created_at DESC`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []lib.Entry
for rows.Next() {
var e lib.Entry
err := rows.Scan(
&e.EntryID, &e.ProjectID, &e.ParentID, &e.Type, &e.Depth,
&e.SearchKey, &e.SearchKey2, &e.Summary, &e.Data, &e.Stage,
&e.AssigneeID, &e.ReturnToID, &e.OriginID,
&e.Version, &e.DeletedAt, &e.DeletedBy, &e.KeyVersion,
&e.CreatedAt, &e.UpdatedAt, &e.CreatedBy,
)
if err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
func (h *Handlers) orgToMap(org *lib.Entry) map[string]any {
result := map[string]any{
"entry_id": org.EntryID,
"type": org.Type,
"created_at": org.CreatedAt,
"created_by": org.CreatedBy,
}
// Decrypt and parse org data
if len(org.Data) > 0 {
key, err := lib.DeriveProjectKey(h.Cfg.MasterKey, org.ProjectID)
if err == nil {
dataText, err := lib.Unpack(key, org.Data)
if err == nil {
var orgData lib.OrgData
if json.Unmarshal([]byte(dataText), &orgData) == nil {
result["name"] = orgData.Name
result["domains"] = orgData.Domains
result["role"] = orgData.Role
result["website"] = orgData.Website
result["description"] = orgData.Description
result["contact_name"] = orgData.ContactName
result["contact_email"] = orgData.ContactEmail
}
}
}
}
return result
}
// CreateOrg handles POST /api/orgs — create a new organization
// ib_admin or super_admin only. Domains required.
func (h *Handlers) CreateOrg(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
// Check if user is super_admin or ib_admin in any project
isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)
isIBAdmin := h.isIBAdminAnywhere(actorID)
if !isSuperAdmin && !isIBAdmin {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Only IB admins or super admins can create organizations")
return
}
var req struct {
Name string `json:"name"`
Domains []string `json:"domains"`
Role string `json:"role"`
Website string `json:"website"`
Description string `json:"description"`
ContactName string `json:"contact_name"`
ContactEmail string `json:"contact_email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
if req.Name == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Organization name required")
return
}
if len(req.Domains) == 0 {
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "At least one domain required")
return
}
// Validate domains are not empty strings
for _, d := range req.Domains {
if strings.TrimSpace(d) == "" {
ErrorResponse(w, http.StatusBadRequest, "invalid_domains", "Empty domain not allowed")
return
}
}
// Normalize domains to lowercase
for i := range req.Domains {
req.Domains[i] = strings.ToLower(strings.TrimSpace(req.Domains[i]))
}
// Valid roles
validRoles := map[string]bool{"seller": true, "buyer": true, "ib": true, "advisor": true}
if req.Role != "" && !validRoles[req.Role] {
ErrorResponse(w, http.StatusBadRequest, "invalid_role", "Role must be one of: seller, buyer, ib, advisor")
return
}
now := time.Now().UnixMilli()
orgID := uuid.New().String()
orgData := lib.OrgData{
Name: req.Name,
Domains: req.Domains,
Role: req.Role,
Website: req.Website,
Description: req.Description,
ContactName: req.ContactName,
ContactEmail: req.ContactEmail,
}
dataJSON, _ := json.Marshal(orgData)
// Organizations use their own ID as project_id for key derivation
entry := &lib.Entry{
EntryID: orgID,
ProjectID: orgID, // Orgs are platform level, use own ID
ParentID: "",
Type: lib.TypeOrganization,
Depth: 0,
SummaryText: req.Name,
DataText: string(dataJSON),
Stage: lib.StageDataroom,
CreatedBy: actorID,
CreatedAt: now,
UpdatedAt: now,
Version: 1,
KeyVersion: 1,
}
// Pack encrypted fields
key, err := lib.DeriveProjectKey(h.Cfg.MasterKey, orgID)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed")
return
}
summary, err := lib.Pack(key, entry.SummaryText)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed")
return
}
data, err := lib.Pack(key, entry.DataText)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed")
return
}
entry.Summary = summary
entry.Data = data
// Direct insert (bypass RBAC since orgs are platform-level)
_, dbErr := h.DB.Conn.Exec(
`INSERT INTO entries (entry_id, project_id, parent_id, type, depth,
search_key, search_key2, summary, data, stage,
assignee_id, return_to_id, origin_id,
version, deleted_at, deleted_by, key_version,
created_at, updated_at, created_by)
VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
entry.EntryID, entry.ProjectID, "", entry.Type, entry.Depth,
entry.SearchKey, entry.SearchKey2, entry.Summary, entry.Data, entry.Stage,
"", "", "",
entry.Version, nil, nil, entry.KeyVersion,
entry.CreatedAt, entry.UpdatedAt, entry.CreatedBy,
)
if dbErr != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create organization")
return
}
JSONResponse(w, http.StatusCreated, map[string]any{
"entry_id": orgID,
"name": req.Name,
"domains": req.Domains,
"role": req.Role,
})
}
func (h *Handlers) isIBAdminAnywhere(userID string) bool {
var count int
h.DB.Conn.QueryRow(
`SELECT COUNT(*) FROM access WHERE user_id = ? AND role = ? AND revoked_at IS NULL`,
userID, lib.RoleIBAdmin,
).Scan(&count)
return count > 0
}
// GetOrg handles GET /api/orgs/{orgID} — get a single organization
func (h *Handlers) GetOrg(w http.ResponseWriter, r *http.Request) {
orgID := chi.URLParam(r, "orgID")
org, err := lib.EntryByID(h.DB, h.Cfg, orgID)
if err != nil || org == nil || org.Type != lib.TypeOrganization {
ErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found")
return
}
JSONResponse(w, http.StatusOK, h.orgToMap(org))
}
// UpdateOrg handles PATCH /api/orgs/{orgID} — update an organization
func (h *Handlers) UpdateOrg(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
orgID := chi.URLParam(r, "orgID")
// Check permissions
isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)
isIBAdmin := h.isIBAdminAnywhere(actorID)
if !isSuperAdmin && !isIBAdmin {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Only IB admins or super admins can update organizations")
return
}
// Get existing org
org, err := lib.EntryByID(h.DB, h.Cfg, orgID)
if err != nil || org == nil || org.Type != lib.TypeOrganization {
ErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found")
return
}
var req struct {
Name *string `json:"name"`
Domains []string `json:"domains"`
Role *string `json:"role"`
Website *string `json:"website"`
Description *string `json:"description"`
ContactName *string `json:"contact_name"`
ContactEmail *string `json:"contact_email"`
Version int `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
// Parse existing org data
var orgData lib.OrgData
if org.DataText != "" {
json.Unmarshal([]byte(org.DataText), &orgData)
}
// Apply updates
if req.Name != nil {
orgData.Name = *req.Name
}
if req.Domains != nil {
if len(req.Domains) == 0 {
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "At least one domain required")
return
}
for i := range req.Domains {
req.Domains[i] = strings.ToLower(strings.TrimSpace(req.Domains[i]))
}
orgData.Domains = req.Domains
}
if req.Role != nil {
validRoles := map[string]bool{"seller": true, "buyer": true, "ib": true, "advisor": true, "": true}
if !validRoles[*req.Role] {
ErrorResponse(w, http.StatusBadRequest, "invalid_role", "Role must be one of: seller, buyer, ib, advisor")
return
}
orgData.Role = *req.Role
}
if req.Website != nil {
orgData.Website = *req.Website
}
if req.Description != nil {
orgData.Description = *req.Description
}
if req.ContactName != nil {
orgData.ContactName = *req.ContactName
}
if req.ContactEmail != nil {
orgData.ContactEmail = *req.ContactEmail
}
dataJSON, _ := json.Marshal(orgData)
org.DataText = string(dataJSON)
org.SummaryText = orgData.Name
org.Version = req.Version
if err := lib.EntryWrite(h.DB, h.Cfg, actorID, org); err != nil {
if err == lib.ErrVersionConflict {
ErrorResponse(w, http.StatusConflict, "version_conflict", err.Error())
return
}
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to update organization")
return
}
JSONResponse(w, http.StatusOK, h.orgToMap(org))
}
// ---------------------------------------------------------------------------
// Deal Org API endpoints (per-project organization links)
// ---------------------------------------------------------------------------
// ListDealOrgs handles GET /api/projects/{projectID}/orgs — list orgs in this deal
func (h *Handlers) ListDealOrgs(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
// Check read access to project
if err := lib.CheckAccessRead(h.DB, actorID, projectID, ""); err != nil {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
// Get all deal_org entries for this project
rows, err := h.DB.Conn.Query(
`SELECT entry_id, project_id, parent_id, type, depth,
search_key, search_key2, summary, data, stage,
assignee_id, return_to_id, origin_id,
version, deleted_at, deleted_by, key_version,
created_at, updated_at, created_by
FROM entries WHERE project_id = ? AND type = 'deal_org' AND deleted_at IS NULL
ORDER BY created_at DESC`, projectID,
)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to list deal organizations")
return
}
defer rows.Close()
projectKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)
var result []map[string]any
for rows.Next() {
var e lib.Entry
err := rows.Scan(
&e.EntryID, &e.ProjectID, &e.ParentID, &e.Type, &e.Depth,
&e.SearchKey, &e.SearchKey2, &e.Summary, &e.Data, &e.Stage,
&e.AssigneeID, &e.ReturnToID, &e.OriginID,
&e.Version, &e.DeletedAt, &e.DeletedBy, &e.KeyVersion,
&e.CreatedAt, &e.UpdatedAt, &e.CreatedBy,
)
if err != nil {
continue
}
dealOrgMap := map[string]any{
"deal_org_id": e.EntryID,
"created_at": e.CreatedAt,
"version": e.Version,
}
// Decrypt deal_org data
if len(e.Data) > 0 {
dataText, err := lib.Unpack(projectKey, e.Data)
if err == nil {
var dealOrgData lib.DealOrgData
if json.Unmarshal([]byte(dataText), &dealOrgData) == nil {
dealOrgMap["org_id"] = dealOrgData.OrgID
dealOrgMap["role"] = dealOrgData.Role
dealOrgMap["domain_lock"] = dealOrgData.DomainLock
// Fetch org details
org, err := lib.EntryByID(h.DB, h.Cfg, dealOrgData.OrgID)
if err == nil && org != nil {
orgDetails := h.orgToMap(org)
dealOrgMap["org_name"] = orgDetails["name"]
dealOrgMap["org_domains"] = orgDetails["domains"]
}
}
}
}
result = append(result, dealOrgMap)
}
if result == nil {
result = []map[string]any{}
}
JSONResponse(w, http.StatusOK, result)
}
// CreateDealOrg handles POST /api/projects/{projectID}/orgs — add org to deal
func (h *Handlers) CreateDealOrg(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
// Check write access to project
if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
var req struct {
OrgID string `json:"org_id"`
Role string `json:"role"`
DomainLock bool `json:"domain_lock"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
if req.OrgID == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "org_id required")
return
}
// Verify org exists
org, err := lib.EntryByID(h.DB, h.Cfg, req.OrgID)
if err != nil || org == nil || org.Type != lib.TypeOrganization {
ErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found")
return
}
// Get project entry to use as parent
project, err := lib.EntryByID(h.DB, h.Cfg, projectID)
if err != nil || project == nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Project not found")
return
}
now := time.Now().UnixMilli()
dealOrgID := uuid.New().String()
dealOrgData := lib.DealOrgData{
OrgID: req.OrgID,
Role: req.Role,
DomainLock: req.DomainLock,
}
dataJSON, _ := json.Marshal(dealOrgData)
entry := &lib.Entry{
EntryID: dealOrgID,
ProjectID: projectID,
ParentID: projectID, // Parent is the project
Type: lib.TypeDealOrg,
Depth: 1,
SummaryText: req.Role,
DataText: string(dataJSON),
Stage: lib.StagePreDataroom,
CreatedBy: actorID,
CreatedAt: now,
UpdatedAt: now,
Version: 1,
KeyVersion: 1,
}
// Pack encrypted fields
key, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed")
return
}
summary, err := lib.Pack(key, entry.SummaryText)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed")
return
}
data, err := lib.Pack(key, entry.DataText)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed")
return
}
entry.Summary = summary
entry.Data = data
_, dbErr := h.DB.Conn.Exec(
`INSERT INTO entries (entry_id, project_id, parent_id, type, depth,
search_key, search_key2, summary, data, stage,
assignee_id, return_to_id, origin_id,
version, deleted_at, deleted_by, key_version,
created_at, updated_at, created_by)
VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
entry.EntryID, entry.ProjectID, entry.ParentID, entry.Type, entry.Depth,
entry.SearchKey, entry.SearchKey2, entry.Summary, entry.Data, entry.Stage,
"", "", "",
entry.Version, nil, nil, entry.KeyVersion,
entry.CreatedAt, entry.UpdatedAt, entry.CreatedBy,
)
if dbErr != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to add organization to deal")
return
}
// Get org name for response
orgName := ""
orgDetails := h.orgToMap(org)
if name, ok := orgDetails["name"].(string); ok {
orgName = name
}
JSONResponse(w, http.StatusCreated, map[string]any{
"deal_org_id": dealOrgID,
"org_id": req.OrgID,
"org_name": orgName,
"role": req.Role,
"domain_lock": req.DomainLock,
})
}
// DeleteDealOrg handles DELETE /api/projects/{projectID}/orgs/{dealOrgID} — remove org from deal
func (h *Handlers) DeleteDealOrg(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
dealOrgID := chi.URLParam(r, "dealOrgID")
// Check delete access to project
if err := lib.CheckAccessDelete(h.DB, actorID, projectID, ""); err != nil {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
// Verify deal_org exists and belongs to this project
entry, err := lib.EntryByID(h.DB, h.Cfg, dealOrgID)
if err != nil || entry == nil || entry.Type != lib.TypeDealOrg || entry.ProjectID != projectID {
ErrorResponse(w, http.StatusNotFound, "not_found", "Deal organization link not found")
return
}
if err := lib.EntryDelete(h.DB, actorID, projectID, dealOrgID); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to remove organization from deal")
return
}
JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// ServeAppOrgs serves the organizations page
func (h *Handlers) ServeAppOrgs(w http.ResponseWriter, r *http.Request) {
h.serveTemplate(w, "app/orgs.html", nil)
}

View File

@ -68,6 +68,17 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
r.Get("/projects/{projectID}/objects/{objectID}", h.DownloadObject) r.Get("/projects/{projectID}/objects/{objectID}", h.DownloadObject)
// Super admin endpoints // Super admin endpoints
// Organizations (platform level)
r.Get("/orgs", h.ListOrgs)
r.Post("/orgs", h.CreateOrg)
r.Get("/orgs/{orgID}", h.GetOrg)
r.Patch("/orgs/{orgID}", h.UpdateOrg)
// Deal orgs (per project)
r.Get("/projects/{projectID}/orgs", h.ListDealOrgs)
r.Post("/projects/{projectID}/orgs", h.CreateDealOrg)
r.Delete("/projects/{projectID}/orgs/{dealOrgID}", h.DeleteDealOrg)
r.Get("/admin/users", h.AdminListUsers) r.Get("/admin/users", h.AdminListUsers)
r.Get("/admin/projects", h.AdminListProjects) r.Get("/admin/projects", h.AdminListProjects)
r.Get("/admin/audit", h.AdminAuditLog) r.Get("/admin/audit", h.AdminAuditLog)
@ -83,6 +94,7 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
r.Get("/app/projects", h.ServeAppProjects) r.Get("/app/projects", h.ServeAppProjects)
r.Get("/app/projects/{id}", h.ServeAppProject) r.Get("/app/projects/{id}", h.ServeAppProject)
r.Get("/app/requests/{id}", h.ServeAppRequest) r.Get("/app/requests/{id}", h.ServeAppRequest)
r.Get("/app/orgs", h.ServeAppOrgs)
// Admin UI (super admin only, auth checked client-side) // Admin UI (super admin only, auth checked client-side)
r.Get("/admin", h.ServeAdmin) r.Get("/admin", h.ServeAdmin)

View File

@ -1,6 +1,7 @@
package lib package lib
import ( import (
"encoding/json"
"errors" "errors"
"strings" "strings"
) )
@ -13,6 +14,10 @@ var (
// CheckAccess verifies that actorID has the required operation on the given project/entry. // CheckAccess verifies that actorID has the required operation on the given project/entry.
// op is one of "r", "w", "d", "m" (read, write, delete, manage). // op is one of "r", "w", "d", "m" (read, write, delete, manage).
// Returns the matching Access grant or an error. // Returns the matching Access grant or an error.
//
// SECURITY: Org membership does NOT grant project access — only explicit access grants count.
// The access table is the SOLE source of truth for project permissions.
// deal_org entries are for domain validation during invite creation only.
func CheckAccess(db *DB, actorID, projectID string, workstreamID string, op string) (*Access, error) { func CheckAccess(db *DB, actorID, projectID string, workstreamID string, op string) (*Access, error) {
// super_admin bypasses all access checks — full rwdm on everything // super_admin bypasses all access checks — full rwdm on everything
if isSA, _ := IsSuperAdmin(db, actorID); isSA { if isSA, _ := IsSuperAdmin(db, actorID); isSA {
@ -175,3 +180,118 @@ func GetUserHighestRole(db *DB, userID, projectID string) (string, error) {
} }
return bestRole, nil return bestRole, nil
} }
var (
ErrDomainMismatch = errors.New("email domain does not match organization requirements")
)
// ValidateOrgDomain checks if an email is ELIGIBLE to be invited to a specific role in a deal.
// This is called ONLY during invite creation for domain validation.
//
// SECURITY: This function answers "is this email allowed to be invited?" — it does NOT grant
// any access whatsoever. Access is granted only by explicit rows in the access table.
// Example: PwC (@pwc.com) may be a buyer in Project A and seller in Project B. A PwC partner
// invited to Project A has ZERO visibility into Project B, even though PwC is listed there.
//
// Returns nil if validation passes (email domain matches or no domain_lock is enforced).
func ValidateOrgDomain(db *DB, cfg *Config, projectID, email, role string) error {
// Find all deal_org entries for the project
rows, err := db.Conn.Query(
`SELECT entry_id, data FROM entries
WHERE project_id = ? AND type = ? AND deleted_at IS NULL`,
projectID, TypeDealOrg,
)
if err != nil {
return err
}
defer rows.Close()
// Get the project key for decryption
projectKey, err := DeriveProjectKey(cfg.MasterKey, projectID)
if err != nil {
return err
}
// Extract email domain
parts := strings.Split(email, "@")
if len(parts) != 2 {
return errors.New("invalid email format")
}
emailDomain := strings.ToLower(parts[1])
foundMatchingRole := false
for rows.Next() {
var entryID string
var dataBlob []byte
if err := rows.Scan(&entryID, &dataBlob); err != nil {
return err
}
// Decrypt and parse deal_org data
if len(dataBlob) == 0 {
continue
}
dataText, err := Unpack(projectKey, dataBlob)
if err != nil {
continue
}
var dealOrgData DealOrgData
if err := json.Unmarshal([]byte(dataText), &dealOrgData); err != nil {
continue
}
// Only check deal_orgs with matching role
if dealOrgData.Role != role {
continue
}
foundMatchingRole = true
// If domain lock is disabled, pass through
if !dealOrgData.DomainLock {
continue
}
// Get the organization entry to check domains
orgEntry, err := entryReadSystem(db, dealOrgData.OrgID)
if err != nil || orgEntry == nil {
continue
}
// Decrypt org data
orgKey, err := DeriveProjectKey(cfg.MasterKey, orgEntry.ProjectID)
if err != nil {
continue
}
if len(orgEntry.Data) == 0 {
continue
}
orgDataText, err := Unpack(orgKey, orgEntry.Data)
if err != nil {
continue
}
var orgData OrgData
if err := json.Unmarshal([]byte(orgDataText), &orgData); err != nil {
continue
}
// Check if email domain matches any of the org's domains
for _, domain := range orgData.Domains {
if strings.ToLower(domain) == emailDomain {
return nil // Match found, validation passes
}
}
}
// If no deal_orgs with matching role and domain_lock=true, pass
if !foundMatchingRole {
return nil
}
// Check if ALL deal_orgs with this role have domain_lock=false
// If we got here and foundMatchingRole=true, that means there's at least one
// with domain_lock=true that didn't match, so return error
return ErrDomainMismatch
}

View File

@ -48,6 +48,8 @@ const (
TypeRequest = "request" TypeRequest = "request"
TypeAnswer = "answer" TypeAnswer = "answer"
TypeComment = "comment" TypeComment = "comment"
TypeOrganization = "organization"
TypeDealOrg = "deal_org"
) )
// Stages // Stages
@ -57,6 +59,25 @@ const (
StageClosed = "closed" StageClosed = "closed"
) )
// OrgData is the JSON structure packed into an organization entry's Data field.
type OrgData struct {
Name string `json:"name"`
Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"]
Role string `json:"role"` // seller | buyer | ib | advisor
Website string `json:"website,omitempty"`
Description string `json:"description,omitempty"`
ContactName string `json:"contact_name,omitempty"`
ContactEmail string `json:"contact_email,omitempty"`
}
// DealOrgData is the JSON structure packed into a deal_org entry's Data field.
// A deal_org entry links an organization into a specific deal (project).
type DealOrgData struct {
OrgID string `json:"org_id"` // entry_id of the organization
Role string `json:"role"` // seller | buyer | ib | advisor
DomainLock bool `json:"domain_lock"` // if true, enforce domain check on invites
}
// User represents an account. // User represents an account.
type User struct { type User struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`

View File

@ -1,240 +1,111 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin — Dealspace</title> <title>Admin — Dealspace</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<style> <style>* { font-family: 'Inter', sans-serif; } body { background: #0a1628; }
* { font-family: 'Inter', sans-serif; } .sidebar-link.active { background: rgba(201,168,76,0.1); color: #c9a84c; border-left: 3px solid #c9a84c; }
body { background: #0a1628; } .sidebar-link:hover:not(.active) { background: rgba(255,255,255,0.04); }
.tab-active { border-bottom: 2px solid #c9a84c; color: #c9a84c; }
.tab-inactive { border-bottom: 2px solid transparent; color: #94a3b8; }
.tab-inactive:hover { color: #e2e8f0; }
</style> </style>
</head> </head>
<body class="min-h-screen text-white"> <body>
<!-- Top bar --> <header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between sticky top-0 z-50">
<header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between"> <a href="/app/tasks" class="text-xl font-bold text-white tracking-tight"><span class="text-[#c9a84c]">Deal</span>space</a>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href="/app/tasks" class="text-xl font-bold tracking-tight"> <span class="text-xs px-2 py-0.5 bg-[#c9a84c]/20 text-[#c9a84c] rounded-full font-medium">Super Admin</span>
<span class="text-[#c9a84c]">Deal</span>space <span id="userName" class="text-sm text-[#94a3b8]"></span>
</a> <button onclick="logout()" class="text-sm text-[#94a3b8] hover:text-white transition">Logout</button>
<span class="text-xs bg-red-500/20 text-red-400 px-2 py-0.5 rounded-full font-medium">SUPER ADMIN</span>
</div>
<div class="flex items-center gap-4">
<a href="/app/tasks" class="text-sm text-[#94a3b8] hover:text-white transition">&larr; Back to app</a>
<button id="logoutBtn" class="text-sm text-[#94a3b8] hover:text-white transition">Sign out</button>
</div> </div>
</header> </header>
<div class="flex">
<nav class="w-56 bg-[#0d1f3c] border-r border-white/[0.08] min-h-[calc(100vh-49px)] sticky top-[49px] shrink-0">
<div class="p-3 space-y-0.5">
<a href="/app/tasks" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
App Home</a>
<a href="/admin" class="sidebar-link active flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Dashboard</a>
</div>
</nav>
<main class="flex-1 p-8 max-w-6xl">
<h1 class="text-2xl font-bold text-white mb-2">Admin Dashboard</h1>
<p class="text-[#94a3b8] text-sm mb-8">Platform overview and management.</p>
<div class="max-w-7xl mx-auto px-6 py-8"> <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<h1 class="text-2xl font-bold mb-6">Platform Administration</h1> <div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5">
<div class="text-[#94a3b8] text-xs uppercase tracking-wider mb-1">Users</div>
<!-- Tabs --> <div id="statUsers" class="text-3xl font-bold text-white"></div>
<div class="flex gap-6 mb-8 border-b border-white/[0.08]"> </div>
<button class="tab-active pb-3 text-sm font-medium" data-tab="users">Users</button> <div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5">
<button class="tab-inactive pb-3 text-sm font-medium" data-tab="projects">Projects</button> <div class="text-[#94a3b8] text-xs uppercase tracking-wider mb-1">Projects</div>
<button class="tab-inactive pb-3 text-sm font-medium" data-tab="audit">Audit Log</button> <div id="statProjects" class="text-3xl font-bold text-white"></div>
</div>
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5">
<div class="text-[#94a3b8] text-xs uppercase tracking-wider mb-1">Organizations</div>
<div id="statOrgs" class="text-3xl font-bold text-white"></div>
</div>
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5">
<div class="text-[#94a3b8] text-xs uppercase tracking-wider mb-1">Active Sessions</div>
<div id="statSessions" class="text-3xl font-bold text-white"></div>
</div>
</div> </div>
<!-- Users tab --> <div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-6">
<div id="tab-users" class="tab-content">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<input type="text" id="userSearch" placeholder="Search by email or name..." <h2 class="text-sm font-semibold text-white">All Users</h2>
class="px-4 py-2 bg-[#0d1f3c] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] w-80">
<span id="userCount" class="text-sm text-[#94a3b8]"></span>
</div>
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-white/[0.08] text-[#94a3b8]">
<th class="text-left px-4 py-3 font-medium">Name</th>
<th class="text-left px-4 py-3 font-medium">Email</th>
<th class="text-left px-4 py-3 font-medium">Organization</th>
<th class="text-left px-4 py-3 font-medium">Status</th>
<th class="text-left px-4 py-3 font-medium">Created</th>
<th class="text-left px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody id="usersTable"></tbody>
</table>
</div>
</div>
<!-- Projects tab -->
<div id="tab-projects" class="tab-content hidden">
<div class="flex items-center justify-between mb-4">
<span id="projectCount" class="text-sm text-[#94a3b8]"></span>
</div>
<div class="grid gap-4" id="projectsGrid"></div>
</div>
<!-- Audit tab -->
<div id="tab-audit" class="tab-content hidden">
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-white/[0.08] text-[#94a3b8]">
<th class="text-left px-4 py-3 font-medium">Time</th>
<th class="text-left px-4 py-3 font-medium">Actor</th>
<th class="text-left px-4 py-3 font-medium">Action</th>
<th class="text-left px-4 py-3 font-medium">Target</th>
<th class="text-left px-4 py-3 font-medium">IP</th>
</tr>
</thead>
<tbody id="auditTable"></tbody>
</table>
</div> </div>
<div id="userList" class="space-y-2"><div class="text-[#94a3b8] text-sm">Loading...</div></div>
</div> </div>
</main>
</div> </div>
<script> <script>
const token = localStorage.getItem('ds_token'); const token = localStorage.getItem('ds_token');
if (!token) window.location.href = '/app/login';
const user = JSON.parse(localStorage.getItem('ds_user') || '{}'); const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
if (!user.is_super_admin) window.location.href = '/app/tasks';
document.getElementById('userName').textContent = user.name || user.email || '';
if (!token || !user.is_super_admin) { function fetchAPI(path, opts = {}) {
window.location.href = '/app/login'; opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
return fetch(path, opts).then(r => { if (r.status === 401) { localStorage.removeItem('ds_token'); window.location.href = '/app/login'; } return r; });
} }
function logout() { fetchAPI('/api/auth/logout', { method: 'POST' }).finally(() => { localStorage.clear(); window.location.href = '/app/login'; }); }
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function fetchAPI(url) { async function loadStats() {
return fetch(url, { headers: { 'Authorization': 'Bearer ' + token } }).then(r => { try {
if (r.status === 401) { localStorage.clear(); window.location.href = '/app/login'; } const [usersRes, projectsRes, orgsRes] = await Promise.all([
return r.json(); fetchAPI('/api/admin/users'), fetchAPI('/api/projects'), fetchAPI('/api/orgs')
}); ]);
} const users = await usersRes.json();
const projects = await projectsRes.json();
const orgs = await orgsRes.json();
document.getElementById('statUsers').textContent = Array.isArray(users) ? users.length : '?';
document.getElementById('statProjects').textContent = Array.isArray(projects) ? projects.length : '?';
document.getElementById('statOrgs').textContent = Array.isArray(orgs) ? orgs.length : '?';
document.getElementById('statSessions').textContent = '—';
function postAPI(url, body) { if (Array.isArray(users) && users.length > 0) {
return fetch(url, { document.getElementById('userList').innerHTML = users.map(u => `
method: 'POST', <div class="flex items-center gap-4 px-4 py-3 rounded-lg bg-[#0a1628] border border-white/[0.05]">
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, <div class="w-8 h-8 rounded-full bg-[#c9a84c]/20 flex items-center justify-center text-[#c9a84c] text-sm font-semibold">${(u.name||u.email||'?')[0].toUpperCase()}</div>
body: JSON.stringify(body) <div class="flex-1">
}).then(r => r.json()); <div class="text-white text-sm font-medium">${escHtml(u.name || u.email)}</div>
} ${u.name ? `<div class="text-[#475569] text-xs">${escHtml(u.email)}</div>` : ''}
function formatDate(ts) {
if (!ts) return '—';
return new Date(ts).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
}
function formatDateTime(ts) {
if (!ts) return '—';
return new Date(ts).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
// Tabs
document.querySelectorAll('[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-tab]').forEach(b => {
b.className = b === btn ? 'tab-active pb-3 text-sm font-medium' : 'tab-inactive pb-3 text-sm font-medium';
});
document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
document.getElementById('tab-' + btn.dataset.tab).classList.remove('hidden');
if (btn.dataset.tab === 'projects') loadProjects();
if (btn.dataset.tab === 'audit') loadAudit();
});
});
// Users
let allUsers = [];
async function loadUsers() {
allUsers = await fetchAPI('/api/admin/users');
renderUsers(allUsers);
}
function renderUsers(users) {
document.getElementById('userCount').textContent = users.length + ' user' + (users.length !== 1 ? 's' : '');
document.getElementById('usersTable').innerHTML = users.map(u => `
<tr class="border-b border-white/[0.05] hover:bg-white/[0.02]">
<td class="px-4 py-3 font-medium">${escapeHtml(u.name)}</td>
<td class="px-4 py-3 text-[#94a3b8]">${escapeHtml(u.email)}</td>
<td class="px-4 py-3 text-[#94a3b8]">${escapeHtml(u.org_name || u.org_id || '—')}</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-full text-xs ${u.active ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}">
${u.active ? 'Active' : 'Inactive'}
</span>
</td>
<td class="px-4 py-3 text-[#94a3b8]">${formatDate(u.created_at)}</td>
<td class="px-4 py-3">
<button onclick="impersonate('${u.user_id}')" class="text-[#c9a84c] hover:underline text-xs">Impersonate</button>
</td>
</tr>
`).join('');
}
document.getElementById('userSearch').addEventListener('input', (e) => {
const q = e.target.value.toLowerCase();
const filtered = allUsers.filter(u =>
u.email.toLowerCase().includes(q) || u.name.toLowerCase().includes(q)
);
renderUsers(filtered);
});
async function impersonate(userId) {
if (!confirm('Impersonate this user? You will be signed in as them.')) return;
const data = await postAPI('/api/admin/impersonate', { user_id: userId });
if (data.token) {
localStorage.setItem('ds_token', data.token);
localStorage.setItem('ds_user', JSON.stringify(data.user));
window.location.href = '/app/tasks';
}
}
// Projects
async function loadProjects() {
const projects = await fetchAPI('/api/admin/projects');
document.getElementById('projectCount').textContent = projects.length + ' project' + (projects.length !== 1 ? 's' : '');
document.getElementById('projectsGrid').innerHTML = projects.map(p => {
let name = p.summary || 'Unnamed Project';
return `
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5">
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold text-white">${escapeHtml(name)}</h3>
<span class="text-xs px-2 py-0.5 rounded-full bg-[#c9a84c]/10 text-[#c9a84c]">${escapeHtml(p.stage)}</span>
</div> </div>
<div class="text-sm text-[#94a3b8]"> ${u.is_super_admin ? '<span class="text-xs px-2 py-0.5 bg-[#c9a84c]/20 text-[#c9a84c] rounded-full">super admin</span>' : ''}
<span>ID: ${p.entry_id.slice(0, 8)}...</span> <span class="text-xs text-[#475569]">${new Date(u.created_at).toLocaleDateString()}</span>
<span class="ml-4">Created: ${formatDate(p.created_at)}</span> </div>`).join('');
</div> } else {
</div> document.getElementById('userList').innerHTML = '<div class="text-[#94a3b8] text-sm">No users found.</div>';
`;
}).join('');
} }
} catch(e) {}
// Audit
async function loadAudit() {
const entries = await fetchAPI('/api/admin/audit');
document.getElementById('auditTable').innerHTML = entries.length === 0
? '<tr><td colspan="5" class="px-4 py-8 text-center text-[#94a3b8]">No audit events yet</td></tr>'
: entries.map(a => `
<tr class="border-b border-white/[0.05] hover:bg-white/[0.02]">
<td class="px-4 py-3 text-[#94a3b8]">${formatDateTime(a.ts)}</td>
<td class="px-4 py-3">${escapeHtml(a.actor_id ? a.actor_id.slice(0, 8) + '...' : '—')}</td>
<td class="px-4 py-3">${escapeHtml(a.action || '—')}</td>
<td class="px-4 py-3 text-[#94a3b8]">${escapeHtml(a.target_id ? a.target_id.slice(0, 8) + '...' : '—')}</td>
<td class="px-4 py-3 text-[#94a3b8]">${escapeHtml(a.ip || '—')}</td>
</tr>
`).join('');
} }
loadStats();
// Logout
document.getElementById('logoutBtn').addEventListener('click', async () => {
try { await fetch('/api/auth/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } }); } catch {}
localStorage.clear();
window.location.href = '/app/login';
});
// Initial load
loadUsers();
</script> </script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Organizations — Dealspace</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<style>* { font-family: 'Inter', sans-serif; } body { background: #0a1628; }
.sidebar-link.active { background: rgba(201,168,76,0.1); color: #c9a84c; border-left: 3px solid #c9a84c; }
.sidebar-link:hover:not(.active) { background: rgba(255,255,255,0.04); }
.card:hover { border-color: rgba(201,168,76,0.3); }
</style>
</head>
<body>
<header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between sticky top-0 z-50">
<a href="/app/tasks" class="text-xl font-bold text-white tracking-tight"><span class="text-[#c9a84c]">Deal</span>space</a>
<div class="flex items-center gap-4">
<span id="userName" class="text-sm text-[#94a3b8]"></span>
<button onclick="logout()" class="text-sm text-[#94a3b8] hover:text-white transition">Logout</button>
</div>
</header>
<div class="flex">
<nav class="w-56 bg-[#0d1f3c] border-r border-white/[0.08] min-h-[calc(100vh-49px)] sticky top-[49px] shrink-0">
<div class="p-3 space-y-0.5">
<a href="/app/tasks" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
My Tasks</a>
<a href="/app/projects" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
Projects</a>
<a href="/app/orgs" class="sidebar-link active flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
Organizations</a>
<div id="adminLinks" class="hidden"><div class="border-t border-white/[0.08] my-3"></div>
<a href="/admin" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Admin</a></div>
</div>
</nav>
<main class="flex-1 p-8 max-w-5xl">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold text-white mb-1">Organizations</h1>
<p class="text-[#94a3b8] text-sm">Company directory — parties eligible to participate in deals.</p>
</div>
<button id="newOrgBtn" class="hidden px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">+ New Organization</button>
</div>
<div id="orgGrid" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="text-[#94a3b8] text-sm col-span-2">Loading...</div>
</div>
<div id="emptyState" class="hidden text-center py-20">
<div class="text-5xl mb-4">🏢</div>
<h2 class="text-xl font-semibold text-white mb-2">No organizations yet</h2>
<p class="text-[#94a3b8] text-sm">Add buyer, seller, IB, and advisor organizations.</p>
</div>
</main>
</div>
<!-- New Org Modal -->
<div id="newOrgModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8 w-full max-w-md">
<h2 class="text-xl font-semibold text-white mb-6">New Organization</h2>
<div id="modalError" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
<div class="space-y-4">
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Organization Name</label>
<input id="oName" type="text" placeholder="James LLC" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c]"></div>
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Allowed Email Domains <span class="text-red-400">*</span></label>
<input id="oDomains" type="text" placeholder="jamesllc.com, kaseya.com" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c]">
<p class="text-[#475569] text-xs mt-1">Comma-separated. Only emails from these domains can be invited for this org.</p></div>
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Default Role</label>
<select id="oRole" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white focus:outline-none focus:border-[#c9a84c]">
<option value="seller">Seller</option><option value="buyer">Buyer</option><option value="ib">IB Advisor</option><option value="advisor">Advisor</option>
</select></div>
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Website</label>
<input id="oWebsite" type="url" placeholder="https://jamesllc.com" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c]"></div>
</div>
<div class="flex gap-3 mt-6">
<button onclick="closeModal()" class="flex-1 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button>
<button id="createBtn" onclick="createOrg()" class="flex-1 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Create</button>
</div>
</div>
</div>
<script>
const token = localStorage.getItem('ds_token');
if (!token) window.location.href = '/app/login';
const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
document.getElementById('userName').textContent = user.name || user.email || '';
if (user.is_super_admin) { document.getElementById('adminLinks').classList.remove('hidden'); document.getElementById('newOrgBtn').classList.remove('hidden'); }
function fetchAPI(path, opts = {}) {
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
return fetch(path, opts).then(r => { if (r.status === 401) { localStorage.removeItem('ds_token'); window.location.href = '/app/login'; } return r; });
}
function logout() { fetchAPI('/api/auth/logout', { method: 'POST' }).finally(() => { localStorage.clear(); window.location.href = '/app/login'; }); }
function parseData(t) { try { return JSON.parse(t); } catch { return {}; } }
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
const roleColors = { seller: 'bg-blue-500/20 text-blue-300', buyer: 'bg-green-500/20 text-green-300', ib: 'bg-[#c9a84c]/20 text-[#c9a84c]', advisor: 'bg-purple-500/20 text-purple-300' };
async function loadOrgs() {
try {
const res = await fetchAPI('/api/orgs');
const orgs = await res.json();
const grid = document.getElementById('orgGrid');
if (!orgs || orgs.length === 0) { grid.classList.add('hidden'); document.getElementById('emptyState').classList.remove('hidden'); return; }
grid.innerHTML = orgs.map(o => {
const d = parseData(o.data_text);
const rc = roleColors[d.role] || 'bg-gray-500/20 text-gray-300';
const domains = Array.isArray(d.domains) ? d.domains : (d.domains ? [d.domains] : []);
return `<div class="card bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-6 transition">
<div class="flex items-start justify-between mb-3">
<h3 class="text-white font-semibold text-lg leading-tight">${escHtml(d.name || o.summary || 'Untitled')}</h3>
<span class="ml-2 shrink-0 px-2 py-0.5 rounded-full text-xs font-medium capitalize ${rc}">${d.role || '?'}</span>
</div>
<div class="flex gap-1.5 flex-wrap mb-3">${domains.map(dm => `<span class="text-xs font-mono text-[#94a3b8] bg-white/[0.05] px-2 py-0.5 rounded">@${escHtml(dm)}</span>`).join('')}</div>
${d.website ? `<a href="${escHtml(d.website)}" target="_blank" class="text-xs text-[#c9a84c] hover:underline">${escHtml(d.website)}</a>` : ''}
</div>`;
}).join('');
} catch(e) { document.getElementById('orgGrid').innerHTML = '<div class="text-red-400 text-sm col-span-2">Failed to load.</div>'; }
}
function openModal() { document.getElementById('newOrgModal').classList.remove('hidden'); document.getElementById('oName').focus(); }
function closeModal() { document.getElementById('newOrgModal').classList.add('hidden'); document.getElementById('modalError').classList.add('hidden'); }
document.getElementById('newOrgBtn').onclick = openModal;
async function createOrg() {
const name = document.getElementById('oName').value.trim();
const domainsRaw = document.getElementById('oDomains').value.trim();
const role = document.getElementById('oRole').value;
const website = document.getElementById('oWebsite').value.trim();
const errEl = document.getElementById('modalError');
const btn = document.getElementById('createBtn');
if (!name) { errEl.textContent = 'Organization name is required'; errEl.classList.remove('hidden'); return; }
if (!domainsRaw) { errEl.textContent = 'At least one email domain is required'; errEl.classList.remove('hidden'); return; }
const domains = domainsRaw.split(',').map(d => d.trim().replace(/^@/, '')).filter(Boolean);
if (!domains.length) { errEl.textContent = 'Invalid domain format'; errEl.classList.remove('hidden'); return; }
btn.disabled = true; btn.textContent = 'Creating...'; errEl.classList.add('hidden');
try {
const res = await fetchAPI('/api/orgs', { method: 'POST', body: JSON.stringify({ name, domains, role, website }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create organization');
closeModal();
document.getElementById('oName').value = '';
document.getElementById('oDomains').value = '';
document.getElementById('oWebsite').value = '';
loadOrgs();
} catch(e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); btn.disabled = false; btn.textContent = 'Create'; }
}
loadOrgs();
</script>
</body>
</html>

View File

@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project — Dealspace</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<style>* { font-family: 'Inter', sans-serif; } body { background: #0a1628; }
.sidebar-link.active { background: rgba(201,168,76,0.1); color: #c9a84c; border-left: 3px solid #c9a84c; }
.sidebar-link:hover:not(.active) { background: rgba(255,255,255,0.04); }
.tab.active { color: #c9a84c; border-bottom: 2px solid #c9a84c; }
.tab { border-bottom: 2px solid transparent; }
.req-row:hover { background: rgba(255,255,255,0.03); }
</style>
</head>
<body>
<header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between sticky top-0 z-50">
<div class="flex items-center gap-3">
<a href="/app/tasks" class="text-xl font-bold text-white tracking-tight"><span class="text-[#c9a84c]">Deal</span>space</a>
<span class="text-white/20">/</span>
<a href="/app/projects" class="text-sm text-[#94a3b8] hover:text-white transition">Projects</a>
<span class="text-white/20">/</span>
<span id="projectName" class="text-sm text-white font-medium">Loading...</span>
</div>
<div class="flex items-center gap-4">
<span id="userName" class="text-sm text-[#94a3b8]"></span>
<button onclick="logout()" class="text-sm text-[#94a3b8] hover:text-white transition">Logout</button>
</div>
</header>
<div class="flex">
<nav class="w-56 bg-[#0d1f3c] border-r border-white/[0.08] min-h-[calc(100vh-49px)] sticky top-[49px] shrink-0">
<div class="p-3 space-y-0.5">
<a href="/app/tasks" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
My Tasks</a>
<a href="/app/projects" class="sidebar-link active flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
Projects</a>
<a href="/app/orgs" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
Organizations</a>
<div id="adminLinks" class="hidden"><div class="border-t border-white/[0.08] my-3"></div>
<a href="/admin" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Admin</a></div>
</div>
</nav>
<main class="flex-1 p-8 max-w-6xl">
<!-- Header -->
<div class="flex items-start justify-between mb-6">
<div>
<div class="flex items-center gap-3 mb-1">
<h1 id="projectTitle" class="text-2xl font-bold text-white">Loading...</h1>
<span id="projectStatus" class="px-2.5 py-0.5 rounded-full text-xs font-medium"></span>
</div>
<p id="projectDesc" class="text-[#94a3b8] text-sm"></p>
</div>
<button id="newRequestBtn" class="px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">+ New Request</button>
</div>
<!-- Tabs -->
<div class="flex gap-6 border-b border-white/[0.08] mb-6">
<button class="tab active pb-3 text-sm font-medium transition" onclick="switchTab('requests', this)">Requests</button>
<button class="tab pb-3 text-sm font-medium text-[#94a3b8] transition" onclick="switchTab('orgs', this)">Organizations</button>
<button class="tab pb-3 text-sm font-medium text-[#94a3b8] transition" onclick="switchTab('team', this)">Team</button>
</div>
<!-- Requests Tab -->
<div id="tab-requests">
<div id="requestList" class="space-y-2"><div class="text-[#94a3b8] text-sm">Loading requests...</div></div>
<div id="requestEmpty" class="hidden text-center py-16">
<div class="text-4xl mb-3">📋</div>
<h3 class="text-lg font-semibold text-white mb-1">No requests yet</h3>
<p class="text-[#94a3b8] text-sm">Create the first data request for this deal.</p>
</div>
</div>
<!-- Orgs Tab -->
<div id="tab-orgs" class="hidden">
<div class="flex justify-between items-center mb-4">
<p class="text-[#94a3b8] text-sm">Organizations participating in this deal.</p>
<button id="addOrgBtn" class="px-3 py-1.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm transition">+ Add Org</button>
</div>
<div id="orgList" class="space-y-3"><div class="text-[#94a3b8] text-sm">Loading...</div></div>
</div>
<!-- Team Tab -->
<div id="tab-team" class="hidden">
<div class="flex justify-between items-center mb-4">
<p class="text-[#94a3b8] text-sm">People with access to this deal.</p>
<button id="inviteBtn" class="px-3 py-1.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">+ Invite</button>
</div>
<div id="teamList" class="space-y-2"><div class="text-[#94a3b8] text-sm">Loading...</div></div>
</div>
</main>
</div>
<script>
const token = localStorage.getItem('ds_token');
if (!token) window.location.href = '/app/login';
const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
const projectID = location.pathname.split('/').pop();
document.getElementById('userName').textContent = user.name || user.email || '';
if (user.is_super_admin) document.getElementById('adminLinks').classList.remove('hidden');
function fetchAPI(path, opts = {}) {
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
return fetch(path, opts).then(r => { if (r.status === 401) { localStorage.removeItem('ds_token'); window.location.href = '/app/login'; } return r; });
}
function logout() { fetchAPI('/api/auth/logout', { method: 'POST' }).finally(() => { localStorage.clear(); window.location.href = '/app/login'; }); }
function parseData(t) { try { return JSON.parse(t); } catch { return {}; } }
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
const statusColors = { active: 'bg-green-500/20 text-green-300', draft: 'bg-gray-500/20 text-gray-300', closed: 'bg-red-500/20 text-red-300' };
const roleColors = { seller: 'bg-blue-500/20 text-blue-300', buyer: 'bg-green-500/20 text-green-300', ib: 'bg-[#c9a84c]/20 text-[#c9a84c]', advisor: 'bg-purple-500/20 text-purple-300' };
async function loadProject() {
try {
const res = await fetchAPI('/api/projects/' + projectID);
if (!res.ok) { document.getElementById('projectTitle').textContent = 'Not found'; return; }
const p = await res.json();
const d = parseData(p.data_text);
const name = d.name || p.summary || 'Untitled';
document.title = name + ' — Dealspace';
document.getElementById('projectName').textContent = name;
document.getElementById('projectTitle').textContent = name;
document.getElementById('projectDesc').textContent = d.description || '';
const status = d.status || 'active';
const sc = statusColors[status] || 'bg-gray-500/20 text-gray-300';
document.getElementById('projectStatus').className = 'px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ' + sc;
document.getElementById('projectStatus').textContent = status;
} catch(e) {}
}
async function loadRequests() {
try {
const res = await fetchAPI('/api/projects/' + projectID + '/entries?type=request');
const items = await res.json();
const list = document.getElementById('requestList');
if (!items || items.length === 0) { list.classList.add('hidden'); document.getElementById('requestEmpty').classList.remove('hidden'); return; }
list.innerHTML = items.map(r => {
const d = parseData(r.data_text);
return `<a href="/app/requests/${r.entry_id}" class="req-row flex items-center gap-4 px-5 py-4 rounded-xl border border-white/[0.08] transition cursor-pointer">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
${d.ref ? `<span class="text-xs font-mono text-[#94a3b8]">${escHtml(d.ref)}</span>` : ''}
<span class="text-white font-medium truncate">${escHtml(d.title || r.summary || 'Untitled')}</span>
</div>
${d.description ? `<p class="text-[#94a3b8] text-xs truncate">${escHtml(d.description)}</p>` : ''}
</div>
<span class="shrink-0 px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${d.status === 'open' ? 'bg-yellow-500/20 text-yellow-300' : d.status === 'answered' ? 'bg-green-500/20 text-green-300' : 'bg-gray-500/20 text-gray-300'}">${d.status || 'open'}</span>
</a>`;
}).join('');
} catch(e) { document.getElementById('requestList').innerHTML = '<div class="text-red-400 text-sm">Failed to load.</div>'; }
}
async function loadOrgs() {
try {
const res = await fetchAPI('/api/projects/' + projectID + '/orgs');
const orgs = await res.json();
const list = document.getElementById('orgList');
if (!orgs || orgs.length === 0) { list.innerHTML = '<div class="text-[#94a3b8] text-sm">No organizations added yet.</div>'; return; }
list.innerHTML = orgs.map(o => {
const d = parseData(o.data_text);
const rc = roleColors[d.role] || 'bg-gray-500/20 text-gray-300';
return `<div class="flex items-center gap-4 px-5 py-4 rounded-xl bg-[#0d1f3c] border border-white/[0.08]">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="text-white font-medium">${escHtml(d.org_name || d.name || 'Unknown')}</span>
<span class="px-2 py-0.5 rounded-full text-xs font-medium capitalize ${rc}">${d.role || '?'}</span>
${d.domain_lock ? '<span class="px-2 py-0.5 rounded-full text-xs bg-white/[0.05] text-[#94a3b8]">🔒 domain locked</span>' : ''}
</div>
${d.domains ? `<div class="flex gap-1.5 flex-wrap">${(Array.isArray(d.domains)?d.domains:[d.domains]).map(dm=>`<span class="text-xs text-[#94a3b8] font-mono bg-white/[0.05] px-2 py-0.5 rounded">@${dm}</span>`).join('')}</div>` : ''}
</div>
</div>`;
}).join('');
} catch(e) {}
}
async function loadTeam() {
try {
const res = await fetchAPI('/api/projects/' + projectID + '/members');
const members = await res.json();
const list = document.getElementById('teamList');
if (!members || members.length === 0) { list.innerHTML = '<div class="text-[#94a3b8] text-sm">No team members yet.</div>'; return; }
list.innerHTML = members.map(m => `
<div class="flex items-center gap-4 px-5 py-3 rounded-xl bg-[#0d1f3c] border border-white/[0.08]">
<div class="w-8 h-8 rounded-full bg-[#c9a84c]/20 flex items-center justify-center text-[#c9a84c] font-semibold text-sm">${(m.name||m.email||'?')[0].toUpperCase()}</div>
<div class="flex-1">
<div class="text-white text-sm font-medium">${escHtml(m.name || m.email)}</div>
${m.name ? `<div class="text-[#94a3b8] text-xs">${escHtml(m.email)}</div>` : ''}
</div>
<span class="text-xs text-[#94a3b8] capitalize">${m.role || 'member'}</span>
</div>`).join('');
} catch(e) {}
}
function switchTab(name, el) {
document.querySelectorAll('.tab').forEach(t => { t.classList.remove('active','text-white'); t.classList.add('text-[#94a3b8]'); });
el.classList.add('active','text-white'); el.classList.remove('text-[#94a3b8]');
document.getElementById('tab-requests').classList.toggle('hidden', name !== 'requests');
document.getElementById('tab-orgs').classList.toggle('hidden', name !== 'orgs');
document.getElementById('tab-team').classList.toggle('hidden', name !== 'team');
if (name === 'orgs') loadOrgs();
if (name === 'team') loadTeam();
}
document.getElementById('newRequestBtn').onclick = () => {
const title = prompt('Request title:');
if (!title) return;
fetchAPI('/api/projects/' + projectID + '/entries', { method: 'POST', body: JSON.stringify({ type: 'request', data: { title, status: 'open' } }) })
.then(r => r.json()).then(d => { if (d.entry_id) window.location.href = '/app/requests/' + d.entry_id; else loadRequests(); });
};
loadProject();
loadRequests();
</script>
</body>
</html>

View File

@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Projects — Dealspace</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<style>* { font-family: 'Inter', sans-serif; } body { background: #0a1628; }
.sidebar-link.active { background: rgba(201,168,76,0.1); color: #c9a84c; border-left: 3px solid #c9a84c; }
.sidebar-link:hover:not(.active) { background: rgba(255,255,255,0.04); }
.card:hover { border-color: rgba(201,168,76,0.3); transform: translateY(-1px); }
</style>
</head>
<body>
<header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between sticky top-0 z-50">
<a href="/app/tasks" class="text-xl font-bold text-white tracking-tight"><span class="text-[#c9a84c]">Deal</span>space</a>
<div class="flex items-center gap-4">
<span id="userName" class="text-sm text-[#94a3b8]"></span>
<button onclick="logout()" class="text-sm text-[#94a3b8] hover:text-white transition">Logout</button>
</div>
</header>
<div class="flex">
<nav class="w-56 bg-[#0d1f3c] border-r border-white/[0.08] min-h-[calc(100vh-49px)] sticky top-[49px] shrink-0">
<div class="p-3 space-y-0.5">
<a href="/app/tasks" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
My Tasks</a>
<a href="/app/projects" class="sidebar-link active flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
Projects</a>
<a href="/app/orgs" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
Organizations</a>
<div id="adminLinks" class="hidden"><div class="border-t border-white/[0.08] my-3"></div>
<a href="/admin" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Admin</a></div>
</div>
</nav>
<main class="flex-1 p-8 max-w-6xl">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold text-white mb-1">Projects</h1>
<p class="text-[#94a3b8] text-sm">All deals you have access to.</p>
</div>
<button id="newProjectBtn" class="hidden px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">+ New Project</button>
</div>
<div id="projectGrid" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div class="text-[#94a3b8] text-sm col-span-3">Loading projects...</div>
</div>
<div id="emptyState" class="hidden text-center py-20">
<div class="text-5xl mb-4">📁</div>
<h2 class="text-xl font-semibold text-white mb-2">No projects yet</h2>
<p class="text-[#94a3b8]">You haven't been added to any deals yet.</p>
</div>
</main>
</div>
<!-- New Project Modal -->
<div id="newProjectModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8 w-full max-w-md">
<h2 class="text-xl font-semibold text-white mb-6">New Project</h2>
<div id="modalError" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
<div class="space-y-4">
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Deal Name</label>
<input id="pName" type="text" placeholder="Project James" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c]"></div>
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Description</label>
<textarea id="pDesc" rows="2" placeholder="Brief description of this deal..." class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] resize-none"></textarea></div>
<div><label class="block text-sm font-medium text-[#94a3b8] mb-1.5">Status</label>
<select id="pStatus" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white focus:outline-none focus:border-[#c9a84c]">
<option value="active">Active</option><option value="draft">Draft</option><option value="closed">Closed</option>
</select></div>
</div>
<div class="flex gap-3 mt-6">
<button onclick="closeModal()" class="flex-1 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button>
<button id="createBtn" onclick="createProject()" class="flex-1 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Create</button>
</div>
</div>
</div>
<script>
const token = localStorage.getItem('ds_token');
if (!token) window.location.href = '/app/login';
const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
document.getElementById('userName').textContent = user.name || user.email || '';
if (user.is_super_admin) {
document.getElementById('adminLinks').classList.remove('hidden');
document.getElementById('newProjectBtn').classList.remove('hidden');
}
function fetchAPI(path, opts = {}) {
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
return fetch(path, opts).then(r => { if (r.status === 401) { localStorage.removeItem('ds_token'); window.location.href = '/app/login'; } return r; });
}
function logout() { fetchAPI('/api/auth/logout', { method: 'POST' }).finally(() => { localStorage.clear(); window.location.href = '/app/login'; }); }
const roleColors = { seller: 'bg-blue-500/20 text-blue-300', buyer: 'bg-green-500/20 text-green-300', ib: 'bg-[#c9a84c]/20 text-[#c9a84c]', advisor: 'bg-purple-500/20 text-purple-300' };
const statusColors = { active: 'bg-green-500/20 text-green-300', draft: 'bg-gray-500/20 text-gray-300', closed: 'bg-red-500/20 text-red-300', completed: 'bg-blue-500/20 text-blue-300' };
async function loadProjects() {
try {
const res = await fetchAPI('/api/projects');
const projects = await res.json();
const grid = document.getElementById('projectGrid');
if (!projects || projects.length === 0) {
grid.classList.add('hidden');
document.getElementById('emptyState').classList.remove('hidden');
return;
}
grid.innerHTML = projects.map(p => {
const d = parseData(p.data_text);
const status = d.status || 'active';
const sc = statusColors[status] || statusColors.active;
return `<a href="/app/projects/${p.entry_id}" class="card block bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-6 transition cursor-pointer">
<div class="flex items-start justify-between mb-3">
<h3 class="text-white font-semibold text-lg leading-tight">${escHtml(d.name || p.summary || 'Untitled')}</h3>
<span class="ml-2 shrink-0 px-2 py-0.5 rounded-full text-xs font-medium ${sc} capitalize">${status}</span>
</div>
${d.description ? `<p class="text-[#94a3b8] text-sm mb-4 line-clamp-2">${escHtml(d.description)}</p>` : '<div class="mb-4"></div>'}
<div class="flex items-center gap-2 text-xs text-[#475569]">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
${new Date(p.created_at).toLocaleDateString('en-US', {month:'short',day:'numeric',year:'numeric'})}
</div>
</a>`;
}).join('');
} catch(e) { document.getElementById('projectGrid').innerHTML = '<div class="text-red-400 text-sm col-span-3">Failed to load projects.</div>'; }
}
function openModal() { document.getElementById('newProjectModal').classList.remove('hidden'); document.getElementById('pName').focus(); }
function closeModal() { document.getElementById('newProjectModal').classList.add('hidden'); document.getElementById('modalError').classList.add('hidden'); }
document.getElementById('newProjectBtn').onclick = openModal;
async function createProject() {
const name = document.getElementById('pName').value.trim();
const desc = document.getElementById('pDesc').value.trim();
const status = document.getElementById('pStatus').value;
const errEl = document.getElementById('modalError');
const btn = document.getElementById('createBtn');
if (!name) { errEl.textContent = 'Deal name is required'; errEl.classList.remove('hidden'); return; }
btn.disabled = true; btn.textContent = 'Creating...';
errEl.classList.add('hidden');
try {
const res = await fetchAPI('/api/projects', { method: 'POST', body: JSON.stringify({ name, description: desc, status }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create project');
window.location.href = '/app/projects/' + data.entry_id;
} catch(e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); btn.disabled = false; btn.textContent = 'Create'; }
}
function parseData(t) { try { return JSON.parse(t); } catch { return {}; } }
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
loadProjects();
</script>
</body>
</html>

View File

@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request — Dealspace</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<style>* { font-family: 'Inter', sans-serif; } body { background: #0a1628; }
.sidebar-link.active { background: rgba(201,168,76,0.1); color: #c9a84c; border-left: 3px solid #c9a84c; }
.sidebar-link:hover:not(.active) { background: rgba(255,255,255,0.04); }
</style>
</head>
<body>
<header class="bg-[#0d1f3c] border-b border-white/[0.08] px-6 py-3 flex items-center justify-between sticky top-0 z-50">
<div class="flex items-center gap-3">
<a href="/app/tasks" class="text-xl font-bold text-white tracking-tight"><span class="text-[#c9a84c]">Deal</span>space</a>
<span class="text-white/20">/</span>
<a href="/app/projects" class="text-sm text-[#94a3b8] hover:text-white transition">Projects</a>
<span class="text-white/20">/</span>
<a id="backToProject" href="/app/projects" class="text-sm text-[#94a3b8] hover:text-white transition">Project</a>
<span class="text-white/20">/</span>
<span id="reqRef" class="text-sm text-white font-medium">Request</span>
</div>
<div class="flex items-center gap-4">
<span id="userName" class="text-sm text-[#94a3b8]"></span>
<button onclick="logout()" class="text-sm text-[#94a3b8] hover:text-white transition">Logout</button>
</div>
</header>
<div class="flex">
<nav class="w-56 bg-[#0d1f3c] border-r border-white/[0.08] min-h-[calc(100vh-49px)] sticky top-[49px] shrink-0">
<div class="p-3 space-y-0.5">
<a href="/app/tasks" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
My Tasks</a>
<a href="/app/projects" class="sidebar-link active flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-white transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
Projects</a>
<div id="adminLinks" class="hidden"><div class="border-t border-white/[0.08] my-3"></div>
<a href="/admin" class="sidebar-link flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-[#94a3b8] transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Admin</a></div>
</div>
</nav>
<main class="flex-1 p-8 max-w-4xl">
<!-- Request Header -->
<div id="reqHeader" class="mb-8">
<div class="flex items-start gap-4 mb-3">
<div class="flex-1">
<h1 id="reqTitle" class="text-2xl font-bold text-white mb-2">Loading...</h1>
<p id="reqDesc" class="text-[#94a3b8] text-sm"></p>
</div>
<span id="reqStatus" class="shrink-0 px-3 py-1 rounded-full text-sm font-medium"></span>
</div>
<div class="flex gap-3 flex-wrap text-xs text-[#475569]">
<span id="reqDue"></span>
<span id="reqAssignee"></span>
</div>
</div>
<!-- Answer / Upload -->
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-6 mb-6">
<h2 class="text-sm font-semibold text-[#94a3b8] uppercase tracking-wider mb-4">Response</h2>
<div id="answers" class="space-y-4 mb-6"></div>
<div id="uploadArea" class="border-2 border-dashed border-white/[0.08] rounded-xl p-8 text-center hover:border-[#c9a84c]/40 transition cursor-pointer" onclick="document.getElementById('fileInput').click()">
<div class="text-3xl mb-2">📎</div>
<p class="text-[#94a3b8] text-sm">Drop files here or click to upload</p>
<p class="text-[#475569] text-xs mt-1">PDF, DOCX, XLSX, images</p>
<input id="fileInput" type="file" multiple class="hidden" onchange="uploadFiles(this.files)">
</div>
<div id="uploadStatus" class="mt-3 text-sm text-[#94a3b8]"></div>
</div>
<!-- Activity / Comments -->
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-6">
<h2 class="text-sm font-semibold text-[#94a3b8] uppercase tracking-wider mb-4">Comments</h2>
<div id="comments" class="space-y-3 mb-4"></div>
<div class="flex gap-3">
<textarea id="commentText" rows="2" placeholder="Add a comment..." class="flex-1 px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] text-sm resize-none"></textarea>
<button onclick="postComment()" class="px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition self-end">Post</button>
</div>
</div>
</main>
</div>
<script>
const token = localStorage.getItem('ds_token');
if (!token) window.location.href = '/app/login';
const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
const reqID = location.pathname.split('/').pop();
document.getElementById('userName').textContent = user.name || user.email || '';
if (user.is_super_admin) document.getElementById('adminLinks').classList.remove('hidden');
function fetchAPI(path, opts = {}) {
opts.headers = { ...opts.headers, 'Authorization': 'Bearer ' + token };
if (opts.body && typeof opts.body === 'string') opts.headers['Content-Type'] = 'application/json';
return fetch(path, opts).then(r => { if (r.status === 401) { localStorage.removeItem('ds_token'); window.location.href = '/app/login'; } return r; });
}
function logout() { fetchAPI('/api/auth/logout', { method: 'POST' }).finally(() => { localStorage.clear(); window.location.href = '/app/login'; }); }
function parseData(t) { try { return JSON.parse(t); } catch { return {}; } }
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
const statusColors = { open: 'bg-yellow-500/20 text-yellow-300', answered: 'bg-green-500/20 text-green-300', closed: 'bg-gray-500/20 text-gray-300', 'under-review': 'bg-blue-500/20 text-blue-300' };
async function loadRequest() {
try {
const res = await fetchAPI('/api/entries/' + reqID);
if (!res.ok) { document.getElementById('reqTitle').textContent = 'Not found'; return; }
const req = await res.json();
const d = parseData(req.data_text);
const title = d.title || req.summary || 'Untitled';
document.title = title + ' — Dealspace';
document.getElementById('reqRef').textContent = d.ref || title;
document.getElementById('reqTitle').textContent = title;
document.getElementById('reqDesc').textContent = d.description || '';
if (req.project_id) document.getElementById('backToProject').href = '/app/projects/' + req.project_id;
const status = d.status || 'open';
const sc = statusColors[status] || 'bg-gray-500/20 text-gray-300';
document.getElementById('reqStatus').className = 'shrink-0 px-3 py-1 rounded-full text-sm font-medium capitalize ' + sc;
document.getElementById('reqStatus').textContent = status;
if (d.due_date) document.getElementById('reqDue').textContent = '📅 Due: ' + d.due_date;
if (d.assignee) document.getElementById('reqAssignee').textContent = '👤 ' + d.assignee;
} catch(e) {}
}
async function loadAnswers() {
try {
const res = await fetchAPI('/api/entries/' + reqID + '/children?type=answer');
const items = await res.json();
const el = document.getElementById('answers');
if (!items || items.length === 0) { el.innerHTML = '<p class="text-[#475569] text-sm">No documents uploaded yet.</p>'; return; }
el.innerHTML = items.map(a => {
const d = parseData(a.data_text);
const name = d.filename || d.name || a.summary || 'Document';
return `<div class="flex items-center gap-3 px-4 py-3 rounded-lg bg-[#0a1628] border border-white/[0.05]">
<span class="text-2xl">${name.endsWith('.pdf') ? '📄' : name.match(/\.(jpg|jpeg|png|gif)$/i) ? '🖼️' : '📎'}</span>
<div class="flex-1 min-w-0">
<div class="text-white text-sm font-medium truncate">${escHtml(name)}</div>
<div class="text-[#475569] text-xs">${new Date(a.created_at).toLocaleString()}</div>
</div>
<a href="/api/entries/${a.entry_id}/download" class="text-[#c9a84c] text-sm hover:underline">Download</a>
</div>`;
}).join('');
} catch(e) {}
}
async function uploadFiles(files) {
const status = document.getElementById('uploadStatus');
for (const file of files) {
status.textContent = 'Uploading ' + file.name + '...';
const fd = new FormData();
fd.append('file', file);
fd.append('parent_id', reqID);
try {
const res = await fetchAPI('/api/upload', { method: 'POST', body: fd });
if (res.ok) { status.textContent = file.name + ' uploaded.'; loadAnswers(); }
else { status.textContent = 'Upload failed for ' + file.name; }
} catch(e) { status.textContent = 'Error: ' + e.message; }
}
setTimeout(() => status.textContent = '', 3000);
}
async function loadComments() {
try {
const res = await fetchAPI('/api/entries/' + reqID + '/children?type=comment');
const items = await res.json();
const el = document.getElementById('comments');
if (!items || items.length === 0) { el.innerHTML = '<p class="text-[#475569] text-sm">No comments yet.</p>'; return; }
el.innerHTML = items.map(c => {
const d = parseData(c.data_text);
return `<div class="flex gap-3">
<div class="w-7 h-7 rounded-full bg-[#c9a84c]/20 flex items-center justify-center text-[#c9a84c] text-xs font-semibold shrink-0 mt-0.5">${(d.author||'?')[0].toUpperCase()}</div>
<div>
<div class="flex items-baseline gap-2 mb-1">
<span class="text-white text-sm font-medium">${escHtml(d.author||'Unknown')}</span>
<span class="text-[#475569] text-xs">${new Date(c.created_at).toLocaleString()}</span>
</div>
<p class="text-[#94a3b8] text-sm">${escHtml(d.text||'')}</p>
</div>
</div>`;
}).join('');
} catch(e) {}
}
async function postComment() {
const text = document.getElementById('commentText').value.trim();
if (!text) return;
try {
const res = await fetchAPI('/api/entries/' + reqID + '/children', { method: 'POST', body: JSON.stringify({ type: 'comment', data: { text, author: user.name || user.email } }) });
if (res.ok) { document.getElementById('commentText').value = ''; loadComments(); }
} catch(e) {}
}
// Drag and drop
const ua = document.getElementById('uploadArea');
ua.addEventListener('dragover', e => { e.preventDefault(); ua.classList.add('border-[#c9a84c]/60'); });
ua.addEventListener('dragleave', () => ua.classList.remove('border-[#c9a84c]/60'));
ua.addEventListener('drop', e => { e.preventDefault(); ua.classList.remove('border-[#c9a84c]/60'); uploadFiles(e.dataTransfer.files); });
loadRequest();
loadAnswers();
loadComments();
</script>
</body>
</html>

View File

@ -77,7 +77,11 @@
</div> </div>
</div> </div>
<p class="text-center text-[#475569] text-xs mt-8">&copy; 2026 Muskepo B.V. — Amsterdam</p> <p class="text-center text-[#475569] text-xs mt-6">
Don&rsquo;t have an account? Dealspace is invite-only.<br>
<a href="/#demo" class="text-[#c9a84c] hover:underline">Request access on muskepo.com</a>
</p>
<p class="text-center text-[#475569] text-xs mt-3">&copy; 2026 Muskepo B.V. — Amsterdam</p>
</div> </div>
<script> <script>
@ -148,12 +152,8 @@
localStorage.setItem('ds_token', data.token); localStorage.setItem('ds_token', data.token);
localStorage.setItem('ds_user', JSON.stringify(data.user)); localStorage.setItem('ds_user', JSON.stringify(data.user));
// Redirect: super admins go to /admin, others to /app/tasks // Everyone lands on /app/tasks — admin panel accessible from nav
if (data.user && data.user.is_super_admin) {
window.location.href = '/admin';
} else {
window.location.href = '/app/tasks'; window.location.href = '/app/tasks';
}
} catch (err) { } catch (err) {
errorEl.textContent = err.message; errorEl.textContent = err.message;
errorEl.classList.remove('hidden'); errorEl.classList.remove('hidden');