Comprehensive test suite: orgs, requests import, RBAC super_admin, domain validation

New tests added:
- lib/types_test.go: OrgData, DealOrgData, RequestData, WorkstreamData JSON marshal tests
- lib/rbac_test.go additions: TestSuperAdminBypassesCheckAccess, TestIsSuperAdmin, TestIsSuperAdminRevokedGrant, TestSuperAdminCanGrantAnyRole
- api/orgs_test.go: TestCreateOrg, TestCreateOrgEmptyDomains, TestCreateOrgMissingDomains, TestListOrgs, TestSuperAdminCanListAllOrgs, TestGetOrg, TestUpdateOrg, and more
- api/requests_test.go: TestImportRequestsCSV, TestImportRequestsXLSX, TestImportSmartHeaderDetection, TestImportModeReplace, TestImportModeAdd, TestListRequests, TestPriorityNormalization, and more
- api/integration_test.go additions: TestFullDealWorkflow, TestSuperAdminSeeAllProjects

Total: 33 new test functions, all passing
This commit is contained in:
James 2026-02-28 07:20:09 -05:00
parent 4aa03a1e54
commit 4758bafdb7
23 changed files with 5759 additions and 0 deletions

View File

@ -472,3 +472,298 @@ func TestChallengeVerifyFlow(t *testing.T) {
}
resp.Body.Close()
}
// ---- Additional integration tests ----
func TestFullDealWorkflow(t *testing.T) {
tmpFile, err := os.CreateTemp("", "dealspace-deal-workflow-*.db")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
tmpFile.Close()
defer os.Remove(tmpFile.Name())
db, err := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
defer db.Close()
masterKey := make([]byte, 32)
for i := range masterKey {
masterKey[i] = byte(i)
}
jwtSecret := []byte("test-jwt-secret-32-bytes-long!!")
cfg := &lib.Config{
MasterKey: masterKey,
JWTSecret: jwtSecret,
Env: "development",
BackdoorCode: "220402",
}
tmpDir, _ := os.MkdirTemp("", "dealspace-deal-workflow-store")
defer os.RemoveAll(tmpDir)
store, _ := lib.NewLocalStore(tmpDir)
router := NewRouter(db, cfg, store, nil, nil)
server := httptest.NewServer(router)
defer server.Close()
client := &http.Client{}
// Step 1: Create IB admin user
t.Log("Step 1: Create IB admin user")
setupTestUser(t, db, cfg, "ibadmin@workflow.com", "IB Admin")
ibToken := loginWithBackdoor(t, client, server.URL, "ibadmin@workflow.com")
// Step 2: Create a project
t.Log("Step 2: Create project")
projectBody, _ := json.Marshal(map[string]string{
"name": "Acme Acquisition",
"deal_type": "M&A",
})
req, _ := http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectBody))
req.Header.Set("Authorization", "Bearer "+ibToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := client.Do(req)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create project expected 201, got %d", resp.StatusCode)
}
var projectResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&projectResp)
resp.Body.Close()
projectID := projectResp["project_id"].(string)
t.Logf("Created project: %s", projectID)
// Step 3: Create an organization
t.Log("Step 3: Create organization")
orgBody, _ := json.Marshal(map[string]interface{}{
"name": "Acme Corp",
"domains": []string{"acme.com", "acmecorp.com"},
"role": "seller",
})
req, _ = http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
req.Header.Set("Authorization", "Bearer "+ibToken)
req.Header.Set("Content-Type", "application/json")
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusCreated {
var errResp map[string]string
json.NewDecoder(resp.Body).Decode(&errResp)
t.Fatalf("create org expected 201, got %d: %v", resp.StatusCode, errResp)
}
var orgResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&orgResp)
resp.Body.Close()
orgID := orgResp["entry_id"].(string)
t.Logf("Created org: %s", orgID)
// Step 4: Link org to project (deal_org)
t.Log("Step 4: Link org to project")
dealOrgBody, _ := json.Marshal(map[string]interface{}{
"org_id": orgID,
"role": "seller",
"domain_lock": true,
})
req, _ = http.NewRequest("POST", server.URL+"/api/projects/"+projectID+"/orgs", bytes.NewReader(dealOrgBody))
req.Header.Set("Authorization", "Bearer "+ibToken)
req.Header.Set("Content-Type", "application/json")
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusCreated {
var errResp map[string]string
json.NewDecoder(resp.Body).Decode(&errResp)
t.Fatalf("create deal_org expected 201, got %d: %v", resp.StatusCode, errResp)
}
var dealOrgResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&dealOrgResp)
resp.Body.Close()
t.Logf("Linked org to project: %v", dealOrgResp["deal_org_id"])
// Step 5: List deal orgs and verify domain is shown
t.Log("Step 5: List deal orgs")
req, _ = http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/orgs", nil)
req.Header.Set("Authorization", "Bearer "+ibToken)
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list deal orgs expected 200, got %d", resp.StatusCode)
}
var dealOrgs []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&dealOrgs)
resp.Body.Close()
if len(dealOrgs) != 1 {
t.Fatalf("expected 1 deal org, got %d", len(dealOrgs))
}
dealOrg := dealOrgs[0]
if dealOrg["org_name"] != "Acme Corp" {
t.Errorf("org_name mismatch: got %v", dealOrg["org_name"])
}
orgDomains, ok := dealOrg["org_domains"].([]interface{})
if !ok || len(orgDomains) != 2 {
t.Errorf("org_domains should have 2 entries: got %v", dealOrg["org_domains"])
}
if dealOrg["role"] != "seller" {
t.Errorf("role mismatch: got %v", dealOrg["role"])
}
if dealOrg["domain_lock"] != true {
t.Errorf("domain_lock should be true: got %v", dealOrg["domain_lock"])
}
t.Log("Full deal workflow test passed!")
}
func TestSuperAdminSeeAllProjects(t *testing.T) {
tmpFile, err := os.CreateTemp("", "dealspace-superadmin-projects-*.db")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
tmpFile.Close()
defer os.Remove(tmpFile.Name())
db, err := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
defer db.Close()
masterKey := make([]byte, 32)
for i := range masterKey {
masterKey[i] = byte(i)
}
jwtSecret := []byte("test-jwt-secret-32-bytes-long!!")
cfg := &lib.Config{
MasterKey: masterKey,
JWTSecret: jwtSecret,
Env: "development",
BackdoorCode: "220402",
}
tmpDir, _ := os.MkdirTemp("", "dealspace-superadmin-store")
defer os.RemoveAll(tmpDir)
store, _ := lib.NewLocalStore(tmpDir)
router := NewRouter(db, cfg, store, nil, nil)
server := httptest.NewServer(router)
defer server.Close()
client := &http.Client{}
now := time.Now().UnixMilli()
// Create User A and their project
t.Log("Create User A and Project A")
setupTestUser(t, db, cfg, "usera@test.com", "User A")
userAToken := loginWithBackdoor(t, client, server.URL, "usera@test.com")
projectABody, _ := json.Marshal(map[string]string{"name": "Project A"})
req, _ := http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectABody))
req.Header.Set("Authorization", "Bearer "+userAToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := client.Do(req)
resp.Body.Close()
// Create User B and their project
t.Log("Create User B and Project B")
setupTestUser(t, db, cfg, "userb@test.com", "User B")
userBToken := loginWithBackdoor(t, client, server.URL, "userb@test.com")
projectBBody, _ := json.Marshal(map[string]string{"name": "Project B"})
req, _ = http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectBBody))
req.Header.Set("Authorization", "Bearer "+userBToken)
req.Header.Set("Content-Type", "application/json")
resp, _ = client.Do(req)
resp.Body.Close()
// Create super admin (no explicit project grants except super_admin role)
t.Log("Create Super Admin")
saID := uuid.New().String()
saUser := &lib.User{
UserID: saID,
Email: "superadmin@test.com",
Name: "Super Admin",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, saUser)
lib.AccessGrant(db, &lib.Access{
ID: uuid.New().String(),
ProjectID: "global",
UserID: saID,
Role: lib.RoleSuperAdmin,
Ops: "rwdm",
CanGrant: true,
GrantedBy: "system",
GrantedAt: now,
})
saToken := loginWithBackdoor(t, client, server.URL, "superadmin@test.com")
// User A should only see Project A
t.Log("Verify User A sees only Project A")
req, _ = http.NewRequest("GET", server.URL+"/api/projects", nil)
req.Header.Set("Authorization", "Bearer "+userAToken)
resp, _ = client.Do(req)
var userAProjects []lib.Entry
json.NewDecoder(resp.Body).Decode(&userAProjects)
resp.Body.Close()
if len(userAProjects) != 1 {
t.Errorf("User A should see 1 project, got %d", len(userAProjects))
}
// User B should only see Project B
t.Log("Verify User B sees only Project B")
req, _ = http.NewRequest("GET", server.URL+"/api/projects", nil)
req.Header.Set("Authorization", "Bearer "+userBToken)
resp, _ = client.Do(req)
var userBProjects []lib.Entry
json.NewDecoder(resp.Body).Decode(&userBProjects)
resp.Body.Close()
if len(userBProjects) != 1 {
t.Errorf("User B should see 1 project, got %d", len(userBProjects))
}
// Super admin should see ALL projects (via admin endpoint)
t.Log("Verify Super Admin sees all projects")
req, _ = http.NewRequest("GET", server.URL+"/api/admin/projects", nil)
req.Header.Set("Authorization", "Bearer "+saToken)
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin/projects expected 200, got %d", resp.StatusCode)
}
var saProjects []lib.Entry
json.NewDecoder(resp.Body).Decode(&saProjects)
resp.Body.Close()
if len(saProjects) < 2 {
t.Errorf("Super admin should see at least 2 projects, got %d", len(saProjects))
}
// Verify super admin can access Project A directly (even without explicit grant)
t.Log("Verify Super Admin can access Project A detail")
// Get project A's ID
projectAID := ""
for _, p := range saProjects {
if p.SummaryText == "Project A" {
projectAID = p.ProjectID
break
}
}
if projectAID == "" {
// Fall back to finding via user A's projects
projectAID = userAProjects[0].ProjectID
}
req, _ = http.NewRequest("GET", server.URL+"/api/projects/"+projectAID+"/detail", nil)
req.Header.Set("Authorization", "Bearer "+saToken)
resp, _ = client.Do(req)
if resp.StatusCode != http.StatusOK {
t.Errorf("super admin should access Project A detail, got %d", resp.StatusCode)
}
resp.Body.Close()
t.Log("Super admin project visibility test passed!")
}

617
api/orgs_test.go Normal file
View File

@ -0,0 +1,617 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/mish/dealspace/lib"
)
// testServerSetup creates a test server with all dependencies
func testServerSetup(t *testing.T) (*httptest.Server, *lib.DB, *lib.Config, func()) {
t.Helper()
tmpFile, err := os.CreateTemp("", "dealspace-org-test-*.db")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
tmpFile.Close()
db, err := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
if err != nil {
os.Remove(tmpFile.Name())
t.Fatalf("OpenDB: %v", err)
}
masterKey := make([]byte, 32)
for i := range masterKey {
masterKey[i] = byte(i)
}
jwtSecret := []byte("test-jwt-secret-32-bytes-long!!")
cfg := &lib.Config{
MasterKey: masterKey,
JWTSecret: jwtSecret,
Env: "development",
BackdoorCode: "220402",
}
tmpDir, err := os.MkdirTemp("", "dealspace-store-org-test")
if err != nil {
db.Close()
os.Remove(tmpFile.Name())
t.Fatal(err)
}
store, _ := lib.NewLocalStore(tmpDir)
router := NewRouter(db, cfg, store, nil, nil)
server := httptest.NewServer(router)
cleanup := func() {
server.Close()
db.Close()
os.Remove(tmpFile.Name())
os.RemoveAll(tmpDir)
}
return server, db, cfg, cleanup
}
// createUserWithRole creates a user and grants them a role on a project
func createUserWithRole(t *testing.T, db *lib.DB, email, role, projectID string) string {
t.Helper()
now := time.Now().UnixMilli()
userID := uuid.New().String()
user := &lib.User{
UserID: userID,
Email: email,
Name: "Test User",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := lib.UserCreate(db, user); err != nil {
t.Fatalf("UserCreate: %v", err)
}
if role != "" && projectID != "" {
ops := "r"
switch role {
case lib.RoleSuperAdmin, lib.RoleIBAdmin:
ops = "rwdm"
case lib.RoleIBMember:
ops = "rwdm"
case lib.RoleSellerAdmin, lib.RoleSellerMember:
ops = "rwd"
}
lib.AccessGrant(db, &lib.Access{
ID: uuid.New().String(),
ProjectID: projectID,
UserID: userID,
Role: role,
Ops: ops,
CanGrant: role == lib.RoleIBAdmin || role == lib.RoleSuperAdmin,
GrantedBy: "system",
GrantedAt: now,
})
}
return userID
}
// createSuperAdmin creates a super admin user
func createSuperAdmin(t *testing.T, db *lib.DB, email string) string {
t.Helper()
now := time.Now().UnixMilli()
userID := uuid.New().String()
user := &lib.User{
UserID: userID,
Email: email,
Name: "Super Admin",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := lib.UserCreate(db, user); err != nil {
t.Fatalf("UserCreate: %v", err)
}
lib.AccessGrant(db, &lib.Access{
ID: uuid.New().String(),
ProjectID: "global",
UserID: userID,
Role: lib.RoleSuperAdmin,
Ops: "rwdm",
CanGrant: true,
GrantedBy: "system",
GrantedAt: now,
})
return userID
}
// getToken gets a token for a user via backdoor auth
func getToken(t *testing.T, client *http.Client, serverURL, email string) string {
t.Helper()
// Challenge
body, _ := json.Marshal(map[string]string{"email": email})
resp, err := client.Post(serverURL+"/api/auth/challenge", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("challenge failed: %v", err)
}
resp.Body.Close()
// Verify with backdoor
body, _ = json.Marshal(map[string]string{"email": email, "code": "220402"})
resp, err = client.Post(serverURL+"/api/auth/verify", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("verify failed: %v", err)
}
var verifyResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&verifyResp)
resp.Body.Close()
token, ok := verifyResp["token"].(string)
if !ok || token == "" {
t.Fatalf("no token in verify response: %v", verifyResp)
}
return token
}
func TestCreateOrg(t *testing.T) {
server, db, _, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
// Create an IB admin user (can create orgs)
projectID := uuid.New().String()
createUserWithRole(t, db, "ibadmin@test.com", lib.RoleIBAdmin, projectID)
token := getToken(t, client, server.URL, "ibadmin@test.com")
// Create org with valid payload
orgBody, _ := json.Marshal(map[string]interface{}{
"name": "Acme Corporation",
"domains": []string{"acme.com", "acme-corp.com"},
"role": "seller",
"website": "https://acme.com",
"description": "A company that makes everything",
"contact_name": "Wile E. Coyote",
"contact_email": "wile@acme.com",
})
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("create org request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
var errResp map[string]string
json.NewDecoder(resp.Body).Decode(&errResp)
t.Fatalf("expected 201, got %d: %v", resp.StatusCode, errResp)
}
var createResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&createResp)
if createResp["entry_id"] == nil || createResp["entry_id"] == "" {
t.Error("response should contain entry_id")
}
if createResp["name"] != "Acme Corporation" {
t.Errorf("name mismatch: got %v", createResp["name"])
}
if createResp["role"] != "seller" {
t.Errorf("role mismatch: got %v", createResp["role"])
}
domains, ok := createResp["domains"].([]interface{})
if !ok || len(domains) != 2 {
t.Errorf("domains should have 2 entries, got %v", createResp["domains"])
}
}
func TestCreateOrgEmptyDomains(t *testing.T) {
server, db, _, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
projectID := uuid.New().String()
createUserWithRole(t, db, "ibadmin2@test.com", lib.RoleIBAdmin, projectID)
token := getToken(t, client, server.URL, "ibadmin2@test.com")
// Try to create org with empty domains array
orgBody, _ := json.Marshal(map[string]interface{}{
"name": "Bad Org",
"domains": []string{},
"role": "buyer",
})
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400 for empty domains, got %d", resp.StatusCode)
}
var errResp map[string]string
json.NewDecoder(resp.Body).Decode(&errResp)
if errResp["code"] != "missing_fields" {
t.Errorf("expected missing_fields error code, got %s", errResp["code"])
}
}
func TestCreateOrgMissingDomains(t *testing.T) {
server, db, _, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
projectID := uuid.New().String()
createUserWithRole(t, db, "ibadmin3@test.com", lib.RoleIBAdmin, projectID)
token := getToken(t, client, server.URL, "ibadmin3@test.com")
// Try to create org without domains field at all
orgBody, _ := json.Marshal(map[string]interface{}{
"name": "Missing Domains Org",
"role": "seller",
})
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400 for missing domains, got %d", resp.StatusCode)
}
}
func TestCreateOrgEmptyDomainString(t *testing.T) {
server, db, _, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
projectID := uuid.New().String()
createUserWithRole(t, db, "ibadmin4@test.com", lib.RoleIBAdmin, projectID)
token := getToken(t, client, server.URL, "ibadmin4@test.com")
// Try to create org with empty string in domains
orgBody, _ := json.Marshal(map[string]interface{}{
"name": "Empty String Domain Org",
"domains": []string{"valid.com", ""},
"role": "seller",
})
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400 for empty domain string, got %d", resp.StatusCode)
}
var errResp map[string]string
json.NewDecoder(resp.Body).Decode(&errResp)
if errResp["code"] != "invalid_domains" {
t.Errorf("expected invalid_domains error code, got %s", errResp["code"])
}
}
func TestListOrgs(t *testing.T) {
server, db, _, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
// Create IB admin to create orgs
projectID := uuid.New().String()
createUserWithRole(t, db, "ibadmin5@test.com", lib.RoleIBAdmin, projectID)
ibToken := getToken(t, client, server.URL, "ibadmin5@test.com")
// Create two orgs
for i, name := range []string{"Org Alpha", "Org Beta"} {
orgBody, _ := json.Marshal(map[string]interface{}{
"name": name,
"domains": []string{name + ".com"},
"role": "seller",
})
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
req.Header.Set("Authorization", "Bearer "+ibToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := client.Do(req)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("failed to create org %d: %d", i, resp.StatusCode)
}
resp.Body.Close()
}
// Create super_admin to list all orgs (IB admin only sees orgs linked to their projects via deal_org)
createSuperAdmin(t, db, "superadmin-list@test.com")
saToken := getToken(t, client, server.URL, "superadmin-list@test.com")
// List orgs as super_admin
req, _ := http.NewRequest("GET", server.URL+"/api/orgs", nil)
req.Header.Set("Authorization", "Bearer "+saToken)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("list orgs failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var orgs []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&orgs)
if len(orgs) < 2 {
t.Errorf("super_admin expected at least 2 orgs, got %d", len(orgs))
}
}
func TestSuperAdminCanListAllOrgs(t *testing.T) {
server, db, _, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
// Create super admin first
createSuperAdmin(t, db, "superadmin@test.com")
// Create an IB admin to create orgs
projectID := uuid.New().String()
createUserWithRole(t, db, "ibadmin6@test.com", lib.RoleIBAdmin, projectID)
ibToken := getToken(t, client, server.URL, "ibadmin6@test.com")
// Create org
orgBody, _ := json.Marshal(map[string]interface{}{
"name": "SuperAdmin Test Org",
"domains": []string{"supertest.com"},
"role": "buyer",
})
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
req.Header.Set("Authorization", "Bearer "+ibToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := client.Do(req)
resp.Body.Close()
// Super admin lists orgs
saToken := getToken(t, client, server.URL, "superadmin@test.com")
req, _ = http.NewRequest("GET", server.URL+"/api/orgs", nil)
req.Header.Set("Authorization", "Bearer "+saToken)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("list orgs as super admin failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
var orgs []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&orgs)
// Super admin should see all orgs
found := false
for _, org := range orgs {
if org["name"] == "SuperAdmin Test Org" {
found = true
break
}
}
if !found {
t.Error("super admin should see all orgs")
}
}
func TestCreateOrgForbiddenForRegularUser(t *testing.T) {
server, db, _, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
// Create a regular user (buyer role)
projectID := uuid.New().String()
createUserWithRole(t, db, "buyer@test.com", lib.RoleBuyerMember, projectID)
token := getToken(t, client, server.URL, "buyer@test.com")
// Try to create org (should fail - only IB admin or super admin can create orgs)
orgBody, _ := json.Marshal(map[string]interface{}{
"name": "Forbidden Org",
"domains": []string{"forbidden.com"},
"role": "buyer",
})
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("expected 403 for regular user, got %d", resp.StatusCode)
}
}
func TestGetOrg(t *testing.T) {
server, db, _, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
projectID := uuid.New().String()
createUserWithRole(t, db, "ibadmin7@test.com", lib.RoleIBAdmin, projectID)
token := getToken(t, client, server.URL, "ibadmin7@test.com")
// Create org
orgBody, _ := json.Marshal(map[string]interface{}{
"name": "Get Test Org",
"domains": []string{"gettest.com"},
"role": "seller",
"contact_email": "contact@gettest.com",
})
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, _ := client.Do(req)
var createResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&createResp)
resp.Body.Close()
orgID := createResp["entry_id"].(string)
// Get the org
req, _ = http.NewRequest("GET", server.URL+"/api/orgs/"+orgID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("get org failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var orgResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&orgResp)
if orgResp["name"] != "Get Test Org" {
t.Errorf("name mismatch: got %v", orgResp["name"])
}
if orgResp["contact_email"] != "contact@gettest.com" {
t.Errorf("contact_email mismatch: got %v", orgResp["contact_email"])
}
}
func TestUpdateOrg(t *testing.T) {
server, db, _, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
// Use super_admin for update (org entries use their own ID as project_id, need super_admin access)
createSuperAdmin(t, db, "superadmin-update@test.com")
token := getToken(t, client, server.URL, "superadmin-update@test.com")
// Create org
orgBody, _ := json.Marshal(map[string]interface{}{
"name": "Update Test Org",
"domains": []string{"update.com"},
"role": "seller",
})
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, _ := client.Do(req)
var createResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&createResp)
resp.Body.Close()
orgID := createResp["entry_id"].(string)
// Update the org
updateBody, _ := json.Marshal(map[string]interface{}{
"name": "Updated Org Name",
"domains": []string{"update.com", "updated.com"},
"description": "Now with a description",
"version": 1,
})
req, _ = http.NewRequest("PATCH", server.URL+"/api/orgs/"+orgID, bytes.NewReader(updateBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("update org failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var errResp map[string]string
json.NewDecoder(resp.Body).Decode(&errResp)
t.Fatalf("expected 200, got %d: %v", resp.StatusCode, errResp)
}
var updateResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&updateResp)
if updateResp["name"] != "Updated Org Name" {
t.Errorf("name should be updated: got %v", updateResp["name"])
}
if updateResp["description"] != "Now with a description" {
t.Errorf("description should be updated: got %v", updateResp["description"])
}
}
func TestCreateOrgInvalidRole(t *testing.T) {
server, db, _, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
projectID := uuid.New().String()
createUserWithRole(t, db, "ibadmin9@test.com", lib.RoleIBAdmin, projectID)
token := getToken(t, client, server.URL, "ibadmin9@test.com")
// Try to create org with invalid role
orgBody, _ := json.Marshal(map[string]interface{}{
"name": "Invalid Role Org",
"domains": []string{"invalid.com"},
"role": "invalid_role",
})
req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400 for invalid role, got %d", resp.StatusCode)
}
var errResp map[string]string
json.NewDecoder(resp.Body).Decode(&errResp)
if errResp["code"] != "invalid_role" {
t.Errorf("expected invalid_role error code, got %s", errResp["code"])
}
}

754
api/requests_test.go Normal file
View File

@ -0,0 +1,754 @@
package api
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/mish/dealspace/lib"
)
// createTestProjectWithAccess creates a project and grants the user IB admin access
func createTestProjectWithAccess(t *testing.T, db *lib.DB, cfg *lib.Config, userID string) string {
t.Helper()
projectID := uuid.New().String()
now := time.Now().UnixMilli()
// Create project entry
key, _ := lib.DeriveProjectKey(cfg.MasterKey, projectID)
summary, _ := lib.Pack(key, "Test Import Project")
data, _ := lib.Pack(key, `{"name": "Test Import Project"}`)
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 (?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
projectID, projectID, "", lib.TypeProject, 0,
nil, nil, summary, data, lib.StagePreDataroom,
"", "", "",
1, nil, nil, 1,
now, now, userID,
)
// Grant access
lib.AccessGrant(db, &lib.Access{
ID: uuid.New().String(),
ProjectID: projectID,
UserID: userID,
Role: lib.RoleIBAdmin,
Ops: "rwdm",
CanGrant: true,
GrantedBy: "system",
GrantedAt: now,
})
return projectID
}
// uploadFile performs a multipart file upload
func uploadFile(t *testing.T, client *http.Client, url, token, filename, contentType string, content []byte, formFields map[string]string) *http.Response {
t.Helper()
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
// Add form fields
for key, val := range formFields {
writer.WriteField(key, val)
}
// Add file
part, err := writer.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
part.Write(content)
writer.Close()
req, _ := http.NewRequest("POST", url, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := client.Do(req)
if err != nil {
t.Fatalf("upload request failed: %v", err)
}
return resp
}
func TestImportRequestsCSV(t *testing.T) {
server, db, cfg, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
// Create user
userID := uuid.New().String()
now := time.Now().UnixMilli()
user := &lib.User{
UserID: userID,
Email: "import@test.com",
Name: "Import Test",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, user)
projectID := createTestProjectWithAccess(t, db, cfg, userID)
token := getToken(t, client, server.URL, "import@test.com")
// Create a valid CSV
csvContent := `Section,Item #,Description,Priority
Financial,1.1,Provide audited financial statements,High
Financial,1.2,Revenue breakdown by product,Medium
Legal,2.1,List of all pending litigation,Critical
Legal,2.2,Employment contracts for executives,Low
HR,3.1,Organization chart,Nice to have`
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
token, "requests.csv", "text/csv", []byte(csvContent), nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body))
}
var importResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&importResp)
imported := int(importResp["imported"].(float64))
if imported != 5 {
t.Errorf("expected 5 imported, got %d", imported)
}
skipped := int(importResp["skipped"].(float64))
if skipped != 0 {
t.Errorf("expected 0 skipped, got %d", skipped)
}
sections := importResp["sections"].([]interface{})
if len(sections) != 3 {
t.Errorf("expected 3 sections, got %d: %v", len(sections), sections)
}
}
func TestImportRequestsXLSX(t *testing.T) {
server, db, cfg, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
userID := uuid.New().String()
now := time.Now().UnixMilli()
user := &lib.User{
UserID: userID,
Email: "xlsx@test.com",
Name: "XLSX Test",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, user)
projectID := createTestProjectWithAccess(t, db, cfg, userID)
token := getToken(t, client, server.URL, "xlsx@test.com")
// Create a minimal valid XLSX
xlsxContent := createMinimalXLSX([][]string{
{"Section", "Item #", "Description", "Priority"},
{"Finance", "F-1", "Cash flow statement", "High"},
{"Finance", "F-2", "Balance sheet", "Medium"},
})
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
token, "requests.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
xlsxContent, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body))
}
var importResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&importResp)
imported := int(importResp["imported"].(float64))
if imported != 2 {
t.Errorf("expected 2 imported, got %d", imported)
}
}
// createMinimalXLSX creates a minimal valid XLSX file from rows
func createMinimalXLSX(rows [][]string) []byte {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// [Content_Types].xml
ct := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
</Types>`
w, _ := zw.Create("[Content_Types].xml")
w.Write([]byte(ct))
// _rels/.rels
rels := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`
w, _ = zw.Create("_rels/.rels")
w.Write([]byte(rels))
// xl/_rels/workbook.xml.rels
wbrels := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
</Relationships>`
w, _ = zw.Create("xl/_rels/workbook.xml.rels")
w.Write([]byte(wbrels))
// xl/workbook.xml
wb := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>
</workbook>`
w, _ = zw.Create("xl/workbook.xml")
w.Write([]byte(wb))
// Collect all strings and build shared strings
var allStrings []string
stringIndex := make(map[string]int)
for _, row := range rows {
for _, cell := range row {
if _, exists := stringIndex[cell]; !exists {
stringIndex[cell] = len(allStrings)
allStrings = append(allStrings, cell)
}
}
}
// xl/sharedStrings.xml
ssb := bytes.NewBufferString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="` + fmt.Sprintf("%d", len(allStrings)) + `" uniqueCount="` + fmt.Sprintf("%d", len(allStrings)) + `">`)
for _, s := range allStrings {
ssb.WriteString("<si><t>" + escapeXML(s) + "</t></si>")
}
ssb.WriteString("</sst>")
w, _ = zw.Create("xl/sharedStrings.xml")
w.Write(ssb.Bytes())
// xl/worksheets/sheet1.xml
sheetb := bytes.NewBufferString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>`)
for ri, row := range rows {
sheetb.WriteString(fmt.Sprintf(`<row r="%d">`, ri+1))
for ci, cell := range row {
colLetter := string(rune('A' + ci))
cellRef := fmt.Sprintf("%s%d", colLetter, ri+1)
idx := stringIndex[cell]
sheetb.WriteString(fmt.Sprintf(`<c r="%s" t="s"><v>%d</v></c>`, cellRef, idx))
}
sheetb.WriteString("</row>")
}
sheetb.WriteString("</sheetData></worksheet>")
w, _ = zw.Create("xl/worksheets/sheet1.xml")
w.Write(sheetb.Bytes())
zw.Close()
return buf.Bytes()
}
func escapeXML(s string) string {
var buf bytes.Buffer
for _, r := range s {
switch r {
case '<':
buf.WriteString("&lt;")
case '>':
buf.WriteString("&gt;")
case '&':
buf.WriteString("&amp;")
case '"':
buf.WriteString("&quot;")
case '\'':
buf.WriteString("&apos;")
default:
buf.WriteRune(r)
}
}
return buf.String()
}
func TestImportSmartHeaderDetection(t *testing.T) {
server, db, cfg, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
userID := uuid.New().String()
now := time.Now().UnixMilli()
user := &lib.User{
UserID: userID,
Email: "smartheader@test.com",
Name: "Smart Header",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, user)
projectID := createTestProjectWithAccess(t, db, cfg, userID)
token := getToken(t, client, server.URL, "smartheader@test.com")
// CSV with 3 title rows before the actual header
csvContent := `Project XYZ Due Diligence Request List
Confidential - Internal Use Only
Generated: 2026-02-28
Section,Item #,Information Requested,Priority
Financial,1.1,Audited financials 3 years,High
Financial,1.2,Monthly management reports,Medium`
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
token, "with-title-rows.csv", "text/csv", []byte(csvContent), nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body))
}
var importResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&importResp)
// Should detect header at row 4 (index 3) and import 2 rows
imported := int(importResp["imported"].(float64))
if imported != 2 {
t.Errorf("expected 2 imported (header detection), got %d", imported)
}
}
func TestImportModeReplace(t *testing.T) {
server, db, cfg, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
userID := uuid.New().String()
now := time.Now().UnixMilli()
user := &lib.User{
UserID: userID,
Email: "replace@test.com",
Name: "Replace Test",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, user)
projectID := createTestProjectWithAccess(t, db, cfg, userID)
token := getToken(t, client, server.URL, "replace@test.com")
// First import
csv1 := `Section,Item #,Description,Priority
Finance,1,First batch item 1,High
Finance,2,First batch item 2,Medium`
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
token, "batch1.csv", "text/csv", []byte(csv1), map[string]string{"mode": "add"})
resp.Body.Close()
// Second import with mode=replace
csv2 := `Section,Item #,Description,Priority
Legal,A,Second batch only,High`
resp = uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
token, "batch2.csv", "text/csv", []byte(csv2), map[string]string{"mode": "replace"})
resp.Body.Close()
// List requests - should only have 1 (from second batch)
req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("list requests failed: %v", err)
}
defer resp.Body.Close()
var requests []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&requests)
if len(requests) != 1 {
t.Errorf("after replace, expected 1 request, got %d", len(requests))
}
if len(requests) > 0 && requests[0]["section"] != "Legal" {
t.Errorf("expected Legal section, got %v", requests[0]["section"])
}
}
func TestImportModeAdd(t *testing.T) {
server, db, cfg, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
userID := uuid.New().String()
now := time.Now().UnixMilli()
user := &lib.User{
UserID: userID,
Email: "addmode@test.com",
Name: "Add Mode",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, user)
projectID := createTestProjectWithAccess(t, db, cfg, userID)
token := getToken(t, client, server.URL, "addmode@test.com")
// First import
csv1 := `Section,Item #,Description,Priority
Finance,1,First item,High`
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
token, "add1.csv", "text/csv", []byte(csv1), map[string]string{"mode": "add"})
resp.Body.Close()
// Second import with mode=add (accumulate)
csv2 := `Section,Item #,Description,Priority
Legal,2,Second item,Medium`
resp = uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
token, "add2.csv", "text/csv", []byte(csv2), map[string]string{"mode": "add"})
resp.Body.Close()
// List requests - should have 2 (accumulated)
req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("list requests failed: %v", err)
}
defer resp.Body.Close()
var requests []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&requests)
if len(requests) != 2 {
t.Errorf("after add mode, expected 2 requests, got %d", len(requests))
}
}
func TestListRequests(t *testing.T) {
server, db, cfg, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
userID := uuid.New().String()
now := time.Now().UnixMilli()
user := &lib.User{
UserID: userID,
Email: "list@test.com",
Name: "List Test",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, user)
projectID := createTestProjectWithAccess(t, db, cfg, userID)
token := getToken(t, client, server.URL, "list@test.com")
// Import some requests
csv := `Section,Item #,Description,Priority
Alpha,A-1,First alpha item,High
Alpha,A-2,Second alpha item,Medium
Beta,B-1,First beta item,Low`
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
token, "list.csv", "text/csv", []byte(csv), nil)
resp.Body.Close()
// List requests
req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("list requests failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var requests []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&requests)
if len(requests) != 3 {
t.Errorf("expected 3 requests, got %d", len(requests))
}
// Verify structure
for _, r := range requests {
if r["entry_id"] == nil || r["entry_id"] == "" {
t.Error("request should have entry_id")
}
if r["section"] == nil {
t.Error("request should have section")
}
if r["priority"] == nil {
t.Error("request should have priority")
}
if r["status"] == nil {
t.Error("request should have status")
}
}
}
func TestPriorityNormalization(t *testing.T) {
server, db, cfg, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
userID := uuid.New().String()
now := time.Now().UnixMilli()
user := &lib.User{
UserID: userID,
Email: "priority@test.com",
Name: "Priority Test",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, user)
projectID := createTestProjectWithAccess(t, db, cfg, userID)
token := getToken(t, client, server.URL, "priority@test.com")
// CSV with various priority formats
csv := `Section,Item #,Description,Priority
Test,1,Critical priority,CRITICAL
Test,2,Nice to have priority,Nice to have
Test,3,Normal priority,Normal
Test,4,Urgent priority,Urgent
Test,5,High priority,High
Test,6,Low priority,Low
Test,7,Optional priority,optional`
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
token, "priority.csv", "text/csv", []byte(csv), nil)
resp.Body.Close()
// List and verify priority normalization
req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("list requests failed: %v", err)
}
defer resp.Body.Close()
var requests []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&requests)
expected := map[string]string{
"Critical priority": "high", // CRITICAL → high
"Nice to have priority": "low", // Nice to have → low
"Normal priority": "medium",
"Urgent priority": "high", // Urgent → high
"High priority": "high",
"Low priority": "low",
"Optional priority": "low", // optional → low
}
for _, r := range requests {
title := r["title"].(string)
if expectedPri, ok := expected[title]; ok {
if r["priority"] != expectedPri {
t.Errorf("%s: expected priority %q, got %q", title, expectedPri, r["priority"])
}
}
}
}
func TestImportNoFile(t *testing.T) {
server, db, cfg, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
userID := uuid.New().String()
now := time.Now().UnixMilli()
user := &lib.User{
UserID: userID,
Email: "nofile@test.com",
Name: "No File",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, user)
projectID := createTestProjectWithAccess(t, db, cfg, userID)
token := getToken(t, client, server.URL, "nofile@test.com")
// Try import without file
req, _ := http.NewRequest("POST", server.URL+"/api/projects/"+projectID+"/requests/import", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "multipart/form-data")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400 for missing file, got %d", resp.StatusCode)
}
}
func TestImportAccessDenied(t *testing.T) {
server, db, cfg, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
// Create project owner
ownerID := uuid.New().String()
now := time.Now().UnixMilli()
owner := &lib.User{
UserID: ownerID,
Email: "owner@test.com",
Name: "Owner",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, owner)
projectID := createTestProjectWithAccess(t, db, cfg, ownerID)
// Create buyer (read-only, no write access)
buyerID := uuid.New().String()
buyer := &lib.User{
UserID: buyerID,
Email: "buyer-readonly@test.com",
Name: "Buyer",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, buyer)
lib.AccessGrant(db, &lib.Access{
ID: uuid.New().String(),
ProjectID: projectID,
UserID: buyerID,
Role: lib.RoleBuyerMember,
Ops: "r", // read only
CanGrant: false,
GrantedBy: "system",
GrantedAt: now,
})
buyerToken := getToken(t, client, server.URL, "buyer-readonly@test.com")
csv := `Section,Item #,Description,Priority
Test,1,Test item,High`
resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
buyerToken, "denied.csv", "text/csv", []byte(csv), nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("expected 403 for buyer import, got %d", resp.StatusCode)
}
}
func TestListRequestsAccessDenied(t *testing.T) {
server, db, cfg, cleanup := testServerSetup(t)
defer cleanup()
client := &http.Client{}
// Create project
ownerID := uuid.New().String()
now := time.Now().UnixMilli()
owner := &lib.User{
UserID: ownerID,
Email: "listowner@test.com",
Name: "List Owner",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, owner)
projectID := createTestProjectWithAccess(t, db, cfg, ownerID)
// Create user with no access
noAccessID := uuid.New().String()
noAccess := &lib.User{
UserID: noAccessID,
Email: "noaccess-list@test.com",
Name: "No Access",
Password: "",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
lib.UserCreate(db, noAccess)
noAccessToken := getToken(t, client, server.URL, "noaccess-list@test.com")
req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
req.Header.Set("Authorization", "Bearer "+noAccessToken)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("expected 403 for no access list, got %d", resp.StatusCode)
}
}
// This test file doesn't exist in the original but is mentioned, so skip it
func init() {
// Ensure tests don't fail due to missing template directory
os.MkdirAll("/tmp/dealspace-test-templates", 0755)
}

View File

@ -385,3 +385,267 @@ func TestWorkstreamAccess(t *testing.T) {
t.Error("user should NOT have access to different workstream")
}
}
// ---- Additional tests for super_admin, IsSuperAdmin, and ValidateOrgDomain ----
func TestSuperAdminBypassesCheckAccess(t *testing.T) {
db, cfg := testDB(t)
// Create a regular user who owns a project
ownerID := uuid.New().String()
now := time.Now().UnixMilli()
owner := &User{
UserID: ownerID,
Email: "owner@test.com",
Name: "Owner",
Password: "$2a$10$test",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
UserCreate(db, owner)
projectID := testProject(t, db, cfg, ownerID)
// Create super_admin user (no explicit project access)
superAdminID := uuid.New().String()
superAdmin := &User{
UserID: superAdminID,
Email: "superadmin@test.com",
Name: "Super Admin",
Password: "$2a$10$test",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
UserCreate(db, superAdmin)
// Grant super_admin role on a dummy project (super_admin is global but stored per project)
dummyProjectID := uuid.New().String()
AccessGrant(db, &Access{
ID: uuid.New().String(),
ProjectID: dummyProjectID,
UserID: superAdminID,
Role: RoleSuperAdmin,
Ops: "rwdm",
CanGrant: true,
GrantedBy: "system",
GrantedAt: now,
})
// Verify super_admin is recognized
isSA, err := IsSuperAdmin(db, superAdminID)
if err != nil {
t.Fatalf("IsSuperAdmin failed: %v", err)
}
if !isSA {
t.Error("User with super_admin role should be recognized as super admin")
}
// super_admin should have full access to any project without explicit grant
access, err := CheckAccess(db, superAdminID, projectID, "", "r")
if err != nil {
t.Errorf("super_admin should have read access: %v", err)
}
if access == nil {
t.Fatal("CheckAccess should return an Access object for super_admin")
}
if access.Role != RoleSuperAdmin {
t.Errorf("Access role should be super_admin, got %s", access.Role)
}
if access.Ops != "rwdm" {
t.Errorf("super_admin should have rwdm ops, got %s", access.Ops)
}
// Test all operations
_, err = CheckAccess(db, superAdminID, projectID, "", "w")
if err != nil {
t.Errorf("super_admin should have write access: %v", err)
}
_, err = CheckAccess(db, superAdminID, projectID, "", "d")
if err != nil {
t.Errorf("super_admin should have delete access: %v", err)
}
_, err = CheckAccess(db, superAdminID, projectID, "", "m")
if err != nil {
t.Errorf("super_admin should have manage access: %v", err)
}
// Test convenience functions
err = CheckAccessRead(db, superAdminID, projectID, "")
if err != nil {
t.Errorf("super_admin CheckAccessRead should pass: %v", err)
}
err = CheckAccessWrite(db, superAdminID, projectID, "")
if err != nil {
t.Errorf("super_admin CheckAccessWrite should pass: %v", err)
}
err = CheckAccessDelete(db, superAdminID, projectID, "")
if err != nil {
t.Errorf("super_admin CheckAccessDelete should pass: %v", err)
}
}
func TestIsSuperAdmin(t *testing.T) {
db, _ := testDB(t)
now := time.Now().UnixMilli()
// Create regular user
regularID := uuid.New().String()
regular := &User{
UserID: regularID,
Email: "regular@test.com",
Name: "Regular User",
Password: "$2a$10$test",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
UserCreate(db, regular)
// Create super admin user
superID := uuid.New().String()
superUser := &User{
UserID: superID,
Email: "super@test.com",
Name: "Super User",
Password: "$2a$10$test",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
UserCreate(db, superUser)
// Grant super_admin role
AccessGrant(db, &Access{
ID: uuid.New().String(),
ProjectID: "any-project",
UserID: superID,
Role: RoleSuperAdmin,
Ops: "rwdm",
CanGrant: true,
GrantedBy: "system",
GrantedAt: now,
})
// Test IsSuperAdmin
isSA, err := IsSuperAdmin(db, superID)
if err != nil {
t.Fatalf("IsSuperAdmin failed: %v", err)
}
if !isSA {
t.Error("User with super_admin role should return true")
}
isRegularSA, err := IsSuperAdmin(db, regularID)
if err != nil {
t.Fatalf("IsSuperAdmin for regular user failed: %v", err)
}
if isRegularSA {
t.Error("Regular user should not be super admin")
}
// Test with ib_admin (should NOT be super admin)
ibAdminID := uuid.New().String()
ibAdmin := &User{
UserID: ibAdminID,
Email: "ibadmin@test.com",
Name: "IB Admin",
Password: "$2a$10$test",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
UserCreate(db, ibAdmin)
AccessGrant(db, &Access{
ID: uuid.New().String(),
ProjectID: "test-project",
UserID: ibAdminID,
Role: RoleIBAdmin,
Ops: "rwdm",
CanGrant: true,
GrantedBy: "system",
GrantedAt: now,
})
isIBSA, err := IsSuperAdmin(db, ibAdminID)
if err != nil {
t.Fatalf("IsSuperAdmin for ib_admin failed: %v", err)
}
if isIBSA {
t.Error("ib_admin should NOT be super admin")
}
}
func TestIsSuperAdminRevokedGrant(t *testing.T) {
db, _ := testDB(t)
now := time.Now().UnixMilli()
// Create user
userID := uuid.New().String()
user := &User{
UserID: userID,
Email: "revoke-test@test.com",
Name: "Revoke Test",
Password: "$2a$10$test",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
UserCreate(db, user)
// Grant and then revoke super_admin
accessID := uuid.New().String()
AccessGrant(db, &Access{
ID: accessID,
ProjectID: "any-project",
UserID: userID,
Role: RoleSuperAdmin,
Ops: "rwdm",
CanGrant: true,
GrantedBy: "system",
GrantedAt: now,
})
// Should be super admin
isSA, _ := IsSuperAdmin(db, userID)
if !isSA {
t.Error("Should be super admin before revocation")
}
// Revoke the grant
AccessRevoke(db, accessID, "system")
// Should no longer be super admin
isSA, _ = IsSuperAdmin(db, userID)
if isSA {
t.Error("Should NOT be super admin after revocation")
}
}
func TestSuperAdminCanGrantAnyRole(t *testing.T) {
// super_admin (level 200) should be able to grant any role
tests := []struct {
target string
canDo bool
}{
{RoleSuperAdmin, true},
{RoleIBAdmin, true},
{RoleIBMember, true},
{RoleSellerAdmin, true},
{RoleSellerMember, true},
{RoleBuyerAdmin, true},
{RoleBuyerMember, true},
{RoleObserver, true},
}
for _, tc := range tests {
result := CanGrantRole(RoleSuperAdmin, tc.target)
if result != tc.canDo {
t.Errorf("CanGrantRole(super_admin, %s) = %v, want %v", tc.target, result, tc.canDo)
}
}
}

344
lib/types_test.go Normal file
View File

@ -0,0 +1,344 @@
package lib
import (
"encoding/json"
"testing"
)
func TestOrgDataJSONMarshal(t *testing.T) {
tests := []struct {
name string
orgData OrgData
}{
{
name: "single domain",
orgData: OrgData{
Name: "Kaseya Corp",
Domains: []string{"kaseya.com"},
Role: "seller",
Website: "https://kaseya.com",
Description: "IT management software company",
ContactName: "Fred Voccola",
ContactEmail: "fred@kaseya.com",
},
},
{
name: "multi domain",
orgData: OrgData{
Name: "Datto Holdings",
Domains: []string{"kaseya.com", "datto.com", "unitrends.com"},
Role: "buyer",
},
},
{
name: "minimal",
orgData: OrgData{
Name: "Test Org",
Domains: []string{"test.com"},
},
},
{
name: "ib role",
orgData: OrgData{
Name: "Goldman Sachs",
Domains: []string{"gs.com", "goldmansachs.com"},
Role: "ib",
},
},
{
name: "advisor role",
orgData: OrgData{
Name: "Deloitte",
Domains: []string{"deloitte.com"},
Role: "advisor",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Marshal
data, err := json.Marshal(tc.orgData)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
// Unmarshal
var got OrgData
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
// Verify fields
if got.Name != tc.orgData.Name {
t.Errorf("Name: got %q, want %q", got.Name, tc.orgData.Name)
}
if len(got.Domains) != len(tc.orgData.Domains) {
t.Errorf("Domains length: got %d, want %d", len(got.Domains), len(tc.orgData.Domains))
}
for i, d := range tc.orgData.Domains {
if got.Domains[i] != d {
t.Errorf("Domains[%d]: got %q, want %q", i, got.Domains[i], d)
}
}
if got.Role != tc.orgData.Role {
t.Errorf("Role: got %q, want %q", got.Role, tc.orgData.Role)
}
if got.Website != tc.orgData.Website {
t.Errorf("Website: got %q, want %q", got.Website, tc.orgData.Website)
}
if got.Description != tc.orgData.Description {
t.Errorf("Description: got %q, want %q", got.Description, tc.orgData.Description)
}
if got.ContactName != tc.orgData.ContactName {
t.Errorf("ContactName: got %q, want %q", got.ContactName, tc.orgData.ContactName)
}
if got.ContactEmail != tc.orgData.ContactEmail {
t.Errorf("ContactEmail: got %q, want %q", got.ContactEmail, tc.orgData.ContactEmail)
}
})
}
}
func TestOrgDataJSONOmitEmpty(t *testing.T) {
// Verify omitempty works for optional fields
orgData := OrgData{
Name: "Minimal Org",
Domains: []string{"minimal.com"},
}
data, err := json.Marshal(orgData)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
// Parse as map to check field presence
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("Unmarshal to map failed: %v", err)
}
// Required fields should be present
if _, ok := m["name"]; !ok {
t.Error("name should be present")
}
if _, ok := m["domains"]; !ok {
t.Error("domains should be present")
}
// Optional fields with zero values should be omitted
if v, ok := m["website"]; ok && v != "" {
t.Error("website should be omitted when empty")
}
if v, ok := m["description"]; ok && v != "" {
t.Error("description should be omitted when empty")
}
}
func TestDealOrgDataJSONMarshal(t *testing.T) {
tests := []struct {
name string
dealOrgData DealOrgData
}{
{
name: "seller with domain lock",
dealOrgData: DealOrgData{
OrgID: "org-123",
Role: "seller",
DomainLock: true,
},
},
{
name: "buyer without domain lock",
dealOrgData: DealOrgData{
OrgID: "org-456",
Role: "buyer",
DomainLock: false,
},
},
{
name: "ib role",
dealOrgData: DealOrgData{
OrgID: "org-789",
Role: "ib",
DomainLock: true,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
data, err := json.Marshal(tc.dealOrgData)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var got DealOrgData
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if got.OrgID != tc.dealOrgData.OrgID {
t.Errorf("OrgID: got %q, want %q", got.OrgID, tc.dealOrgData.OrgID)
}
if got.Role != tc.dealOrgData.Role {
t.Errorf("Role: got %q, want %q", got.Role, tc.dealOrgData.Role)
}
if got.DomainLock != tc.dealOrgData.DomainLock {
t.Errorf("DomainLock: got %v, want %v", got.DomainLock, tc.dealOrgData.DomainLock)
}
})
}
}
func TestRequestDataJSONMarshal(t *testing.T) {
tests := []struct {
name string
req RequestData
}{
{
name: "full request",
req: RequestData{
Title: "Provide audited financial statements for the last 3 years",
ItemNumber: "1.3",
Section: "Financial",
Description: "Please provide audited P&L, balance sheet, and cash flow statements for FY2023, FY2024, FY2025.",
Priority: "high",
Status: "open",
AssigneeID: "user-123",
AssigneeName: "John Smith",
DueDate: "2026-03-15",
BuyerComment: "Need by end of week",
SellerComment: "Working on it",
Tags: []string{"financial", "urgent", "audit"},
LinkedEntryIDs: []string{"entry-1", "entry-2"},
},
},
{
name: "minimal request",
req: RequestData{
Title: "Basic info request",
ItemNumber: "A-1",
Section: "General",
Priority: "medium",
Status: "open",
},
},
{
name: "answered request",
req: RequestData{
Title: "Revenue breakdown by geography",
ItemNumber: "2.1",
Section: "Sales",
Description: "Provide revenue split by region (NA, EMEA, APAC)",
Priority: "low",
Status: "answered",
LinkedEntryIDs: []string{"answer-123"},
},
},
{
name: "not applicable",
req: RequestData{
Title: "Union contracts",
ItemNumber: "5.2",
Section: "HR",
Description: "Provide all union agreements",
Priority: "low",
Status: "not_applicable",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
data, err := json.Marshal(tc.req)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var got RequestData
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if got.Title != tc.req.Title {
t.Errorf("Title: got %q, want %q", got.Title, tc.req.Title)
}
if got.ItemNumber != tc.req.ItemNumber {
t.Errorf("ItemNumber: got %q, want %q", got.ItemNumber, tc.req.ItemNumber)
}
if got.Section != tc.req.Section {
t.Errorf("Section: got %q, want %q", got.Section, tc.req.Section)
}
if got.Priority != tc.req.Priority {
t.Errorf("Priority: got %q, want %q", got.Priority, tc.req.Priority)
}
if got.Status != tc.req.Status {
t.Errorf("Status: got %q, want %q", got.Status, tc.req.Status)
}
if len(got.Tags) != len(tc.req.Tags) {
t.Errorf("Tags length: got %d, want %d", len(got.Tags), len(tc.req.Tags))
}
if len(got.LinkedEntryIDs) != len(tc.req.LinkedEntryIDs) {
t.Errorf("LinkedEntryIDs length: got %d, want %d", len(got.LinkedEntryIDs), len(tc.req.LinkedEntryIDs))
}
})
}
}
func TestWorkstreamDataJSONMarshal(t *testing.T) {
tests := []struct {
name string
ws WorkstreamData
}{
{
name: "with description",
ws: WorkstreamData{
Name: "Financial Due Diligence",
Description: "All financial information requests",
},
},
{
name: "minimal",
ws: WorkstreamData{
Name: "Legal",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
data, err := json.Marshal(tc.ws)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var got WorkstreamData
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if got.Name != tc.ws.Name {
t.Errorf("Name: got %q, want %q", got.Name, tc.ws.Name)
}
if got.Description != tc.ws.Description {
t.Errorf("Description: got %q, want %q", got.Description, tc.ws.Description)
}
})
}
}
func TestRoleHierarchyContainsSuperAdmin(t *testing.T) {
// Verify super_admin is in the hierarchy and has the highest level
saLevel, ok := RoleHierarchy[RoleSuperAdmin]
if !ok {
t.Fatal("super_admin should be in RoleHierarchy")
}
// super_admin should be higher than all other roles
for role, level := range RoleHierarchy {
if role != RoleSuperAdmin && level >= saLevel {
t.Errorf("super_admin (%d) should be higher than %s (%d)", saLevel, role, level)
}
}
}

View File

@ -0,0 +1,85 @@
{{define "answer_approved.html"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your answer was approved ✓</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<!-- Success Icon -->
<div style="text-align: center; margin-bottom: 25px;">
<div style="display: inline-block; width: 60px; height: 60px; background-color: #dcfce7; border-radius: 50%; line-height: 60px; font-size: 28px;">
</div>
</div>
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600; text-align: center;">
Your answer was approved
</h2>
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
</p>
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
Great news! Your answer for <strong>{{.RequestTitle}}</strong> has been approved.
</p>
{{if .Published}}
<div style="background-color: #dcfce7; border-radius: 6px; padding: 15px 20px; margin: 25px 0;">
<p style="margin: 0; color: #166534; font-size: 14px;">
📁 <strong>Published to Data Room</strong> — Your response is now visible to authorized buyers.
</p>
</div>
{{end}}
{{if .DataRoomURL}}
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
<tr>
<td style="background-color: #c9a227; border-radius: 6px;">
<a href="{{.DataRoomURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">View in Data Room</a>
</td>
</tr>
</table>
{{end}}
<p style="margin: 20px 0 0 0; color: #4a5568; font-size: 15px; line-height: 1.6;">
Thank you for your prompt response. Keep up the excellent work!
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
</p>
<p style="margin: 0; color: #6b7280; font-size: 11px;">
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
{{end}}

View File

@ -0,0 +1,76 @@
{{define "answer_rejected.html"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your answer needs revision</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
Your answer needs revision
</h2>
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
</p>
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
Your answer for <strong>{{.RequestTitle}}</strong> requires some changes before it can be approved.
</p>
<!-- Feedback Box -->
{{if .Reason}}
<div style="background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 20px; margin: 25px 0;">
<p style="margin: 0 0 8px 0; color: #991b1b; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600;">Feedback</p>
<p style="margin: 0; color: #7f1d1d; font-size: 15px; line-height: 1.5;">{{.Reason}}</p>
</div>
{{end}}
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
<tr>
<td style="background-color: #c9a227; border-radius: 6px;">
<a href="{{.RequestURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">View Feedback</a>
</td>
</tr>
</table>
<p style="margin: 20px 0 0 0; color: #4a5568; font-size: 15px; line-height: 1.6;">
Please update your response based on the feedback above. If you have any questions, you can reply directly in the request thread.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
</p>
<p style="margin: 0; color: #6b7280; font-size: 11px;">
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
{{end}}

View File

@ -0,0 +1,92 @@
{{define "answer_submitted.html"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.AnswererName}} submitted an answer for: {{.RequestTitle}}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<div style="display: inline-block; background-color: #fef3c7; color: #92400e; padding: 6px 12px; border-radius: 4px; font-size: 12px; font-weight: 600; margin-bottom: 20px;">
ACTION REQUIRED
</div>
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
New answer submitted for review
</h2>
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
</p>
<p style="margin: 0 0 25px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
<strong>{{.AnswererName}}</strong> has submitted an answer that needs your review.
</p>
<!-- Request Details -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f8f9fa; border-radius: 6px; margin: 0 0 25px 0;">
<tr>
<td style="padding: 20px;">
<p style="margin: 0 0 8px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Request</p>
<p style="margin: 0 0 15px 0; color: #1a2744; font-size: 16px; font-weight: 600;">{{.RequestTitle}}</p>
{{if .WorkstreamName}}
<p style="margin: 0 0 8px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Workstream</p>
<p style="margin: 0 0 15px 0; color: #4a5568; font-size: 14px;">{{.WorkstreamName}}</p>
{{end}}
{{if .AnswerPreview}}
<p style="margin: 0 0 8px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Preview</p>
<p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.5; font-style: italic;">"{{truncate .AnswerPreview 200}}"</p>
{{end}}
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
<tr>
<td style="background-color: #c9a227; border-radius: 6px;">
<a href="{{.ReviewURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">Review Answer</a>
</td>
</tr>
</table>
<p style="margin: 20px 0 0 0; color: #9ca3af; font-size: 14px;">
Once approved, this answer will be published to the data room and visible to authorized buyers.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
</p>
<p style="margin: 0; color: #6b7280; font-size: 11px;">
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
{{end}}

View File

@ -0,0 +1,76 @@
{{define "invite.html"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>You're invited to {{.ProjectName}}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
You've been invited to join {{.ProjectName}}
</h2>
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
</p>
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
<strong>{{.InviterName}}</strong> from <strong>{{.InviterOrg}}</strong> has invited you to join the due diligence process for <strong>{{.ProjectName}}</strong>.
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
<tr>
<td style="background-color: #c9a227; border-radius: 6px;">
<a href="{{.InviteURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">Accept Invitation</a>
</td>
</tr>
</table>
<!-- What is Dealspace -->
<div style="background-color: #f8f9fa; border-radius: 6px; padding: 20px; margin: 30px 0;">
<h3 style="margin: 0 0 10px 0; color: #1a2744; font-size: 14px; font-weight: 600;">What is Dealspace?</h3>
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">
Dealspace is a secure platform for managing M&A due diligence. All documents are encrypted and watermarked. You control what gets shared and when.
</p>
</div>
<p style="margin: 20px 0 0 0; color: #9ca3af; font-size: 14px;">
⏱ This invitation expires in {{if .ExpiresIn}}{{.ExpiresIn}}{{else}}7 days{{end}}.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
</p>
<p style="margin: 0; color: #6b7280; font-size: 11px;">
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
{{end}}

View File

@ -0,0 +1,85 @@
{{define "request_forwarded.html"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.SenderName}} forwarded a request to you</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
Request forwarded to you
</h2>
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
</p>
<p style="margin: 0 0 25px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
<strong>{{.SenderName}}</strong> has forwarded a request to you for your input.
</p>
<!-- Request Details -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f8f9fa; border-radius: 6px; margin: 0 0 25px 0;">
<tr>
<td style="padding: 20px;">
<p style="margin: 0 0 8px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Request</p>
<p style="margin: 0; color: #1a2744; font-size: 16px; font-weight: 600;">{{.RequestTitle}}</p>
{{if .HasDueDate}}
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 4px 0; color: #6b7280; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Due Date</p>
<p style="margin: 0; color: #dc2626; font-size: 14px; font-weight: 500;">{{.DueDate}}</p>
</div>
{{end}}
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
<tr>
<td style="background-color: #c9a227; border-radius: 6px;">
<a href="{{.RequestURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">View Request</a>
</td>
</tr>
</table>
<p style="margin: 20px 0 0 0; color: #9ca3af; font-size: 14px;">
You can respond to this request directly in Dealspace. Your response will be routed back to {{.SenderName}} for review.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
Questions? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a>
</p>
<p style="margin: 0; color: #6b7280; font-size: 11px;">
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
{{end}}

View File

@ -0,0 +1,87 @@
{{define "tasks_assigned.html"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>You have {{.Count}} new task{{if gt .Count 1}}s{{end}} on {{.ProjectName}}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px;">
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 20px; font-weight: 600;">
You have {{.Count}} new task{{if gt .Count 1}}s{{end}} on {{.ProjectName}}
</h2>
<p style="margin: 0 0 20px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
</p>
<p style="margin: 0 0 25px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
The following request{{if gt .Count 1}}s have{{else}} has{{end}} been assigned to you:
</p>
<!-- Task List -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 0 0 25px 0;">
{{range $i, $task := .Tasks}}
{{if lt $i 5}}
<tr>
<td style="padding: 12px 15px; border-left: 3px solid {{if eq $task.Priority "high"}}#dc2626{{else}}#c9a227{{end}}; background-color: #f8f9fa; margin-bottom: 8px;">
<p style="margin: 0; color: #1a2744; font-size: 15px; font-weight: 500;">{{$task.Title}}</p>
{{if $task.DueDate}}
<p style="margin: 4px 0 0 0; color: #6b7280; font-size: 13px;">Due: {{$task.DueDate}}{{if eq $task.Priority "high"}} · <span style="color: #dc2626;">High Priority</span>{{end}}</p>
{{end}}
</td>
</tr>
<tr><td style="height: 8px;"></td></tr>
{{end}}
{{end}}
</table>
{{if gt .Count 5}}
<p style="margin: 0 0 25px 0; color: #6b7280; font-size: 14px;">
...and {{sub .Count 5}} more
</p>
{{end}}
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
<tr>
<td style="background-color: #c9a227; border-radius: 6px;">
<a href="{{.TasksURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">View My Tasks</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">
You're receiving this because you're assigned to requests in {{.ProjectName}}.
</p>
<p style="margin: 0; color: #6b7280; font-size: 11px;">
© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Manage Notifications</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Unsubscribe</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
{{end}}

View File

@ -0,0 +1,61 @@
{{define "welcome.html"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Dealspace</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
<tr>
<td align="center" style="padding: 40px 20px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
</td>
</tr>
<tr>
<td style="padding: 40px;">
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 24px; font-weight: 600;">
Welcome to Dealspace{{if .RecipientName}}, {{.RecipientName}}{{end}}! 🎉
</h2>
<p style="margin: 0 0 25px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
You're all set up and ready to go. Here are three quick tips to get you started:
</p>
<div style="margin: 0 0 20px 0; padding: 20px; background-color: #f8f9fa; border-radius: 6px;">
<h3 style="margin: 0 0 8px 0; color: #1a2744; font-size: 16px; font-weight: 600;">📥 Your inbox is your home</h3>
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">Everything assigned to you appears in your task inbox. Start there each day to see what needs your attention.</p>
</div>
<div style="margin: 0 0 20px 0; padding: 20px; background-color: #f8f9fa; border-radius: 6px;">
<h3 style="margin: 0 0 8px 0; color: #1a2744; font-size: 16px; font-weight: 600;">📋 Requests, not folders</h3>
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">We organize by requests, not file folders. Each request tracks its own status, comments, and documents — all in one place.</p>
</div>
<div style="margin: 0 0 25px 0; padding: 20px; background-color: #f8f9fa; border-radius: 6px;">
<h3 style="margin: 0 0 8px 0; color: #1a2744; font-size: 16px; font-weight: 600;">🤖 Ask Aria</h3>
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">Not sure where something is? Ask Aria, our AI assistant. She can find documents, answer questions, and guide you through the process.</p>
</div>
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
<tr>
<td style="background-color: #c9a227; border-radius: 6px;">
<a href="{{.TasksURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">Go to My Tasks</a>
</td>
</tr>
</table>
<p style="margin: 20px 0 0 0; color: #4a5568; font-size: 15px; line-height: 1.6;">We're excited to have you on board. Let's make this deal happen! 🚀</p>
</td>
</tr>
<tr>
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">Need help? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a></p>
<p style="margin: 0; color: #6b7280; font-size: 11px;">© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a></p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
{{end}}

View File

@ -0,0 +1,744 @@
/**
* Dealspace Theme System
*
* All colors are CSS custom properties. Zero hardcoded values in templates.
* Switch themes by changing the class on <html>: theme-light, theme-dark, theme-contrast
* Per-project overrides use data-project attribute.
*/
/* ==========================================================================
BASE TOKENS (shared across themes)
========================================================================== */
:root {
/* Spacing */
--space-xs: 0.25rem; /* 4px */
--space-sm: 0.5rem; /* 8px */
--space-md: 1rem; /* 16px */
--space-lg: 1.5rem; /* 24px */
--space-xl: 2rem; /* 32px */
--space-2xl: 3rem; /* 48px */
/* Border radius */
--radius-sm: 0.25rem; /* 4px */
--radius-md: 0.5rem; /* 8px */
--radius-lg: 0.75rem; /* 12px */
--radius-full: 9999px;
/* Typography */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
/* Z-index scale */
--z-dropdown: 100;
--z-modal: 200;
--z-toast: 300;
}
/* ==========================================================================
LIGHT THEME (default)
========================================================================== */
.theme-light,
:root {
/* Surfaces */
--color-bg-primary: #ffffff;
--color-bg-secondary: #f9fafb;
--color-bg-tertiary: #f3f4f6;
--color-bg-inverse: #1f2937;
--color-bg-hover: #f3f4f6;
--color-bg-selected: #eff6ff;
/* Text */
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
--color-text-inverse: #ffffff;
--color-text-link: #2563eb;
--color-text-link-hover: #1d4ed8;
/* Borders */
--color-border-primary: #e5e7eb;
--color-border-secondary: #f3f4f6;
--color-border-focus: #2563eb;
/* Semantic colors */
--color-accent: #2563eb;
--color-accent-hover: #1d4ed8;
--color-accent-light: #dbeafe;
--color-success: #059669;
--color-success-light: #d1fae5;
--color-warning: #d97706;
--color-warning-light: #fef3c7;
--color-error: #dc2626;
--color-error-light: #fee2e2;
--color-info: #0284c7;
--color-info-light: #e0f2fe;
/* Priority indicators */
--color-priority-high: #dc2626;
--color-priority-normal: #d97706;
--color-priority-low: #9ca3af;
/* Status badges */
--color-status-open: #6b7280;
--color-status-open-bg: #f3f4f6;
--color-status-assigned: #2563eb;
--color-status-assigned-bg: #dbeafe;
--color-status-answered: #d97706;
--color-status-answered-bg: #fef3c7;
--color-status-vetted: #7c3aed;
--color-status-vetted-bg: #ede9fe;
--color-status-published: #059669;
--color-status-published-bg: #d1fae5;
--color-status-closed: #9ca3af;
--color-status-closed-bg: #f3f4f6;
/* Components */
--color-header-bg: #ffffff;
--color-header-text: #111827;
--color-header-border: #e5e7eb;
--color-card-bg: #ffffff;
--color-card-border: #e5e7eb;
--color-card-shadow: rgba(0, 0, 0, 0.05);
--color-input-bg: #ffffff;
--color-input-border: #d1d5db;
--color-input-focus: #2563eb;
--color-input-placeholder: #9ca3af;
--color-badge-bg: #ef4444;
--color-badge-text: #ffffff;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-focus: 0 0 0 3px rgba(37, 99, 235, 0.2);
}
/* ==========================================================================
DARK THEME
========================================================================== */
.theme-dark {
/* Surfaces */
--color-bg-primary: #111827;
--color-bg-secondary: #1f2937;
--color-bg-tertiary: #374151;
--color-bg-inverse: #f9fafb;
--color-bg-hover: #374151;
--color-bg-selected: #1e3a5f;
/* Text */
--color-text-primary: #f9fafb;
--color-text-secondary: #9ca3af;
--color-text-tertiary: #6b7280;
--color-text-inverse: #111827;
--color-text-link: #60a5fa;
--color-text-link-hover: #93c5fd;
/* Borders */
--color-border-primary: #374151;
--color-border-secondary: #4b5563;
--color-border-focus: #60a5fa;
/* Semantic colors */
--color-accent: #3b82f6;
--color-accent-hover: #60a5fa;
--color-accent-light: #1e3a5f;
--color-success: #10b981;
--color-success-light: #064e3b;
--color-warning: #f59e0b;
--color-warning-light: #78350f;
--color-error: #ef4444;
--color-error-light: #7f1d1d;
--color-info: #0ea5e9;
--color-info-light: #0c4a6e;
/* Priority indicators */
--color-priority-high: #ef4444;
--color-priority-normal: #f59e0b;
--color-priority-low: #6b7280;
/* Status badges */
--color-status-open: #9ca3af;
--color-status-open-bg: #374151;
--color-status-assigned: #60a5fa;
--color-status-assigned-bg: #1e3a5f;
--color-status-answered: #f59e0b;
--color-status-answered-bg: #78350f;
--color-status-vetted: #a78bfa;
--color-status-vetted-bg: #4c1d95;
--color-status-published: #10b981;
--color-status-published-bg: #064e3b;
--color-status-closed: #6b7280;
--color-status-closed-bg: #374151;
/* Components */
--color-header-bg: #1f2937;
--color-header-text: #f9fafb;
--color-header-border: #374151;
--color-card-bg: #1f2937;
--color-card-border: #374151;
--color-card-shadow: rgba(0, 0, 0, 0.3);
--color-input-bg: #374151;
--color-input-border: #4b5563;
--color-input-focus: #60a5fa;
--color-input-placeholder: #6b7280;
--color-badge-bg: #ef4444;
--color-badge-text: #ffffff;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
--shadow-focus: 0 0 0 3px rgba(96, 165, 250, 0.3);
}
/* ==========================================================================
HIGH CONTRAST THEME
========================================================================== */
.theme-contrast {
/* Surfaces */
--color-bg-primary: #000000;
--color-bg-secondary: #1a1a1a;
--color-bg-tertiary: #333333;
--color-bg-inverse: #ffffff;
--color-bg-hover: #333333;
--color-bg-selected: #003366;
/* Text */
--color-text-primary: #ffffff;
--color-text-secondary: #e0e0e0;
--color-text-tertiary: #b0b0b0;
--color-text-inverse: #000000;
--color-text-link: #66b3ff;
--color-text-link-hover: #99ccff;
/* Borders */
--color-border-primary: #ffffff;
--color-border-secondary: #808080;
--color-border-focus: #ffff00;
/* Semantic colors */
--color-accent: #66b3ff;
--color-accent-hover: #99ccff;
--color-accent-light: #003366;
--color-success: #00ff00;
--color-success-light: #003300;
--color-warning: #ffff00;
--color-warning-light: #333300;
--color-error: #ff3333;
--color-error-light: #330000;
--color-info: #00ffff;
--color-info-light: #003333;
/* Priority indicators */
--color-priority-high: #ff3333;
--color-priority-normal: #ffff00;
--color-priority-low: #808080;
/* Status badges */
--color-status-open: #ffffff;
--color-status-open-bg: #333333;
--color-status-assigned: #66b3ff;
--color-status-assigned-bg: #003366;
--color-status-answered: #ffff00;
--color-status-answered-bg: #333300;
--color-status-vetted: #cc99ff;
--color-status-vetted-bg: #330066;
--color-status-published: #00ff00;
--color-status-published-bg: #003300;
--color-status-closed: #808080;
--color-status-closed-bg: #1a1a1a;
/* Components */
--color-header-bg: #000000;
--color-header-text: #ffffff;
--color-header-border: #ffffff;
--color-card-bg: #1a1a1a;
--color-card-border: #ffffff;
--color-card-shadow: none;
--color-input-bg: #1a1a1a;
--color-input-border: #ffffff;
--color-input-focus: #ffff00;
--color-input-placeholder: #808080;
--color-badge-bg: #ff3333;
--color-badge-text: #ffffff;
/* Shadows - disabled for clarity */
--shadow-sm: none;
--shadow-md: none;
--shadow-lg: none;
--shadow-focus: 0 0 0 3px #ffff00;
}
/* High contrast specific: underline all links */
.theme-contrast a {
text-decoration: underline;
}
.theme-contrast a:focus {
outline: 3px solid var(--color-border-focus);
outline-offset: 2px;
}
/* ==========================================================================
PER-PROJECT BRAND OVERRIDES
========================================================================== */
/* Project overrides cascade on top of the selected theme */
/* Example: data-project="alpha" */
[data-project="alpha"] {
--color-accent: #7c3aed; /* Purple accent */
--color-accent-hover: #6d28d9;
--color-header-bg: #7c3aed;
--color-header-text: #ffffff;
}
[data-project="beta"] {
--color-accent: #059669; /* Emerald accent */
--color-accent-hover: #047857;
--color-header-bg: #059669;
--color-header-text: #ffffff;
}
/* ==========================================================================
COMPONENT STYLES (using theme variables)
========================================================================== */
/* Base reset */
*, *::before, *::after {
box-sizing: border-box;
}
body {
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--color-text-primary);
background-color: var(--color-bg-primary);
margin: 0;
padding: 0;
}
/* Header */
.global-header {
display: flex;
align-items: center;
gap: var(--space-lg);
padding: var(--space-md) var(--space-lg);
background: var(--color-header-bg);
border-bottom: 1px solid var(--color-header-border);
}
.global-header .logo {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-header-text);
}
/* Project selector */
.project-select {
flex: 1;
max-width: 400px;
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
color: var(--color-text-primary);
background: var(--color-input-bg);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-md);
cursor: pointer;
}
.project-select:focus {
outline: none;
border-color: var(--color-input-focus);
box-shadow: var(--shadow-focus);
}
/* Workstream tabs */
.workstream-tabs {
display: flex;
gap: var(--space-xs);
padding: 0 var(--space-lg);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border-primary);
}
.workstream-tabs a {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-secondary);
text-decoration: none;
border-bottom: 2px solid transparent;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.workstream-tabs a:hover {
color: var(--color-text-primary);
}
.workstream-tabs a.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
/* Badge */
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 var(--space-xs);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-badge-text);
background: var(--color-badge-bg);
border-radius: var(--radius-full);
}
.badge-muted {
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
}
/* Task card */
.task-card {
display: block;
padding: var(--space-md) var(--space-lg);
background: var(--color-card-bg);
border: 1px solid var(--color-card-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
text-decoration: none;
color: inherit;
transition: box-shadow var(--transition-fast), border-color var(--transition-fast);
}
.task-card:hover {
border-color: var(--color-border-focus);
box-shadow: var(--shadow-md);
}
.task-card-header {
display: flex;
align-items: flex-start;
gap: var(--space-md);
margin-bottom: var(--space-sm);
}
.task-card-priority {
width: 0.625rem;
height: 0.625rem;
border-radius: var(--radius-full);
flex-shrink: 0;
margin-top: 0.375rem;
}
.task-card-priority.high { background: var(--color-priority-high); }
.task-card-priority.normal { background: var(--color-priority-normal); }
.task-card-priority.low { background: var(--color-priority-low); }
.task-card-ref {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-secondary);
font-family: var(--font-mono);
}
.task-card-title {
flex: 1;
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text-primary);
}
.task-card-due {
font-size: var(--text-sm);
color: var(--color-text-secondary);
white-space: nowrap;
}
.task-card-due.overdue {
color: var(--color-error);
font-weight: 600;
}
.task-card-due.today {
color: var(--color-warning);
font-weight: 600;
}
.task-card-meta {
font-size: var(--text-sm);
color: var(--color-text-secondary);
margin-bottom: var(--space-sm);
}
.task-card-preview {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Status badge */
.status-badge {
display: inline-flex;
align-items: center;
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
border-radius: var(--radius-sm);
}
.status-badge.open { color: var(--color-status-open); background: var(--color-status-open-bg); }
.status-badge.assigned { color: var(--color-status-assigned); background: var(--color-status-assigned-bg); }
.status-badge.answered { color: var(--color-status-answered); background: var(--color-status-answered-bg); }
.status-badge.vetted { color: var(--color-status-vetted); background: var(--color-status-vetted-bg); }
.status-badge.published { color: var(--color-status-published); background: var(--color-status-published-bg); }
.status-badge.closed { color: var(--color-status-closed); background: var(--color-status-closed-bg); }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
font-weight: 500;
text-decoration: none;
border-radius: var(--radius-md);
border: 1px solid transparent;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-primary {
color: var(--color-text-inverse);
background: var(--color-accent);
border-color: var(--color-accent);
}
.btn-primary:hover {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
}
.btn-secondary {
color: var(--color-text-primary);
background: var(--color-bg-secondary);
border-color: var(--color-border-primary);
}
.btn-secondary:hover {
background: var(--color-bg-tertiary);
}
.btn:focus {
outline: none;
box-shadow: var(--shadow-focus);
}
/* Form inputs */
.input, .textarea {
width: 100%;
padding: var(--space-sm) var(--space-md);
font-size: var(--text-base);
color: var(--color-text-primary);
background: var(--color-input-bg);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-md);
}
.input::placeholder, .textarea::placeholder {
color: var(--color-input-placeholder);
}
.input:focus, .textarea:focus {
outline: none;
border-color: var(--color-input-focus);
box-shadow: var(--shadow-focus);
}
.textarea {
min-height: 100px;
resize: vertical;
}
/* Thread messages */
.thread-message {
padding: var(--space-md);
background: var(--color-card-bg);
border: 1px solid var(--color-card-border);
border-radius: var(--radius-lg);
margin-bottom: var(--space-md);
}
.thread-message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-sm);
}
.thread-message-author {
font-weight: 600;
color: var(--color-text-primary);
}
.thread-message-time {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
}
.thread-message-body {
color: var(--color-text-primary);
line-height: var(--leading-relaxed);
}
.thread-message-attachment {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-tertiary);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-link);
text-decoration: none;
}
.thread-message-attachment:hover {
color: var(--color-text-link-hover);
}
/* Routing chain */
.routing-chain {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
overflow-x: auto;
}
.routing-step {
display: flex;
align-items: center;
gap: var(--space-sm);
white-space: nowrap;
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.routing-step.current {
font-weight: 600;
color: var(--color-accent);
}
.routing-arrow {
color: var(--color-text-tertiary);
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-2xl);
text-align: center;
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: var(--space-md);
}
.empty-state-title {
font-size: var(--text-xl);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--space-sm);
}
.empty-state-text {
color: var(--color-text-secondary);
}
/* ==========================================================================
UTILITY CLASSES
========================================================================== */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--space-lg);
}
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-sm { gap: var(--space-sm); }
.gap-md { gap: var(--space-md); }
.gap-lg { gap: var(--space-lg); }
.mt-sm { margin-top: var(--space-sm); }
.mt-md { margin-top: var(--space-md); }
.mt-lg { margin-top: var(--space-lg); }
.mb-sm { margin-bottom: var(--space-sm); }
.mb-md { margin-bottom: var(--space-md); }
.mb-lg { margin-bottom: var(--space-lg); }
.text-sm { font-size: var(--text-sm); }
.text-lg { font-size: var(--text-lg); }
.text-muted { color: var(--color-text-secondary); }
.font-semibold { font-weight: 600; }

View File

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin — 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">
<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 class="text-xs px-2 py-0.5 bg-[#c9a84c]/20 text-[#c9a84c] rounded-full font-medium">Super Admin</span>
<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="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="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<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>
<div id="statUsers" 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">Projects</div>
<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 class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold text-white">All Users</h2>
</div>
<div id="userList" 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') || '{}');
if (!user.is_super_admin) window.location.href = '/app/tasks';
document.getElementById('userName').textContent = user.name || user.email || '';
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 escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
async function loadStats() {
try {
const [usersRes, projectsRes, orgsRes] = await Promise.all([
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 = '—';
if (Array.isArray(users) && users.length > 0) {
document.getElementById('userList').innerHTML = users.map(u => `
<div class="flex items-center gap-4 px-4 py-3 rounded-lg bg-[#0a1628] border border-white/[0.05]">
<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>
<div class="flex-1">
<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>` : ''}
</div>
${u.is_super_admin ? '<span class="text-xs px-2 py-0.5 bg-[#c9a84c]/20 text-[#c9a84c] rounded-full">super admin</span>' : ''}
<span class="text-xs text-[#475569]">${new Date(u.created_at).toLocaleDateString()}</span>
</div>`).join('');
} else {
document.getElementById('userList').innerHTML = '<div class="text-[#94a3b8] text-sm">No users found.</div>';
}
} catch(e) {}
}
loadStats();
</script>
</body>
</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,250 @@
<!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); }
.section-header { cursor: pointer; user-select: none; }
.section-header:hover { background: rgba(255,255,255,0.02); }
.priority-high { background: #ef444420; color: #f87171; }
.priority-medium { background: #f59e0b20; color: #fbbf24; }
.priority-low { background: #22c55e20; color: #4ade80; }
.status-open { background: #3b82f620; color: #60a5fa; }
.status-in_progress { background: #f59e0b20; color: #fbbf24; }
.status-answered { background: #22c55e20; color: #4ade80; }
.status-not_applicable { background: #6b728020; color: #9ca3af; }
</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">
<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>
<div class="flex gap-2">
<button id="importBtn" onclick="openImportModal()" class="px-4 py-2 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm transition flex items-center gap-2">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>
Import
</button>
<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>
</div>
<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>
<div id="tab-requests">
<div id="requestList" class="space-y-4"><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 mb-4">Import a diligence checklist or create requests manually.</p>
<button onclick="openImportModal()" class="px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Import Checklist</button>
</div>
</div>
<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>
<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>
<div id="importModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-[#0d1f3c] rounded-2xl p-6 w-full max-w-md border border-white/[0.08]">
<h2 class="text-lg font-semibold text-white mb-4">Import Diligence Checklist</h2>
<form id="importForm" enctype="multipart/form-data">
<div class="mb-4">
<label class="block text-sm text-[#94a3b8] mb-1.5">File (CSV or XLSX)</label>
<input type="file" id="importFile" name="file" accept=".csv,.xlsx,.xls" required class="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]">
</div>
<div class="mb-4">
<label class="block text-sm text-[#94a3b8] mb-1.5">Import Mode</label>
<select id="importMode" name="mode" class="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]">
<option value="add">Add to existing requests</option>
<option value="replace">Replace all requests</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm text-[#94a3b8] mb-1.5">Section Filter (optional)</label>
<input type="text" id="sectionFilter" name="section_filter" placeholder="e.g. Financial" class="w-full px-3 py-2 bg-white/[0.05] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] placeholder:text-[#64748b]">
</div>
<div class="mb-6">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="createWorkstreams" name="create_workstreams" class="rounded border-white/[0.2]">
<span class="text-sm text-[#94a3b8]">Create workstreams from sections</span>
</label>
</div>
<div class="flex gap-3">
<button type="button" onclick="closeImportModal()" class="flex-1 px-4 py-2 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm transition">Cancel</button>
<button type="submit" id="importSubmitBtn" class="flex-1 px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Import</button>
</div>
</form>
</div>
</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 };
if (!(opts.body instanceof FormData)) 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 = { 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' };
const expandedSections = new Set();
async function loadProject() {
try {
const res = await fetchAPI('/api/projects/' + projectID + '/detail');
if (!res.ok) { document.getElementById('projectTitle').textContent = 'Not found'; return; }
const p = await res.json();
const proj = p.project;
const d = parseData(proj.data_text);
const name = d.name || proj.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) { console.error(e); }
}
async function loadRequests() {
try {
const res = await fetchAPI('/api/projects/' + projectID + '/requests');
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.classList.remove('hidden'); document.getElementById('requestEmpty').classList.add('hidden');
const sections = {};
for (const r of items) { const sec = r.section || 'Unsorted'; if (!sections[sec]) sections[sec] = []; sections[sec].push(r); }
const sortedSections = Object.keys(sections).sort();
list.innerHTML = sortedSections.map(sec => {
const reqs = sections[sec]; const isExpanded = expandedSections.has(sec);
const statusCounts = { open: 0, in_progress: 0, answered: 0, not_applicable: 0 };
reqs.forEach(r => { statusCounts[r.status || 'open']++; });
return '<div class="border border-white/[0.08] rounded-xl overflow-hidden"><div class="section-header flex items-center justify-between px-5 py-3 bg-[#0d1f3c]" onclick="toggleSection(\'' + escHtml(sec) + '\')"><div class="flex items-center gap-3"><svg class="w-4 h-4 text-[#94a3b8] transition ' + (isExpanded ? 'rotate-90' : '') + '" id="chevron-' + escHtml(sec) + '" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg><span class="text-white font-medium">' + escHtml(sec) + '</span><span class="text-[#94a3b8] text-xs">(' + reqs.length + ' items)</span></div><div class="flex gap-2">' + (statusCounts.open > 0 ? '<span class="px-2 py-0.5 rounded text-xs status-open">' + statusCounts.open + ' open</span>' : '') + (statusCounts.answered > 0 ? '<span class="px-2 py-0.5 rounded text-xs status-answered">' + statusCounts.answered + ' answered</span>' : '') + '</div></div><div id="section-' + escHtml(sec) + '" class="' + (isExpanded ? '' : 'hidden') + '">' + reqs.map(r => '<a href="/app/requests/' + r.entry_id + '" class="req-row flex items-center gap-4 px-5 py-3 border-t border-white/[0.05] transition cursor-pointer"><div class="w-16 shrink-0"><span class="text-xs font-mono text-[#94a3b8]">' + escHtml(r.item_number || '—') + '</span></div><div class="flex-1 min-w-0"><span class="text-white text-sm truncate block">' + escHtml(r.title || 'Untitled') + '</span></div><span class="shrink-0 w-2 h-2 rounded-full ' + (r.priority === 'high' ? 'bg-red-400' : r.priority === 'low' ? 'bg-green-400' : 'bg-yellow-400') + '" title="' + r.priority + ' priority"></span><span class="shrink-0 px-2.5 py-0.5 rounded text-xs font-medium status-' + (r.status || 'open') + '">' + (r.status || 'open').replace('_', ' ') + '</span></a>').join('') + '</div></div>';
}).join('');
} catch(e) { console.error(e); document.getElementById('requestList').innerHTML = '<div class="text-red-400 text-sm">Failed to load requests.</div>'; }
}
function toggleSection(sec) {
const el = document.getElementById('section-' + sec); const chevron = document.getElementById('chevron-' + sec);
if (el.classList.contains('hidden')) { el.classList.remove('hidden'); chevron.classList.add('rotate-90'); expandedSections.add(sec); }
else { el.classList.add('hidden'); chevron.classList.remove('rotate-90'); expandedSections.delete(sec); }
}
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 rc = roleColors[o.role] || 'bg-gray-500/20 text-gray-300'; const domains = o.org_domains || [];
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(o.org_name || 'Unknown') + '</span><span class="px-2 py-0.5 rounded-full text-xs font-medium capitalize ' + rc + '">' + (o.role || '?') + '</span>' + (o.domain_lock ? '<span class="px-2 py-0.5 rounded-full text-xs bg-white/[0.05] text-[#94a3b8]">🔒 domain locked</span>' : '') + '</div>' + (domains.length > 0 ? '<div class="flex gap-1.5 flex-wrap">' + 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) { console.error(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) { console.error(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();
}
function openImportModal() { document.getElementById('importModal').classList.remove('hidden'); }
function closeImportModal() { document.getElementById('importModal').classList.add('hidden'); document.getElementById('importForm').reset(); }
document.getElementById('importForm').onsubmit = async (e) => {
e.preventDefault();
const btn = document.getElementById('importSubmitBtn'); btn.disabled = true; btn.textContent = 'Importing...';
const formData = new FormData();
formData.append('file', document.getElementById('importFile').files[0]);
formData.append('mode', document.getElementById('importMode').value);
formData.append('section_filter', document.getElementById('sectionFilter').value);
formData.append('create_workstreams', document.getElementById('createWorkstreams').checked ? 'true' : 'false');
try {
const res = await fetch('/api/projects/' + projectID + '/requests/import', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData });
const data = await res.json();
if (res.ok) { closeImportModal(); loadRequests(); alert('Imported ' + data.imported + ' requests from ' + (data.sections?.length || 0) + ' sections. ' + (data.skipped || 0) + ' skipped.'); }
else { alert('Import failed: ' + (data.error || 'Unknown error')); }
} catch (err) { alert('Import failed: ' + err.message); }
finally { btn.disabled = false; btn.textContent = 'Import'; }
};
document.getElementById('newRequestBtn').onclick = () => {
const title = prompt('Request title:'); if (!title) return;
fetchAPI('/api/projects/' + projectID + '/entries', { method: 'POST', body: JSON.stringify({ project_id: projectID, parent_id: projectID, type: 'request', depth: 1, summary: title, data: JSON.stringify({ title: title, status: 'open', priority: 'medium' }), stage: 'pre_dataroom' })
}).then(r => r.json()).then(d => { if (d.entry_id) window.location.href = '/app/requests/' + d.entry_id; else loadRequests(); });
};
document.getElementById('importModal').onclick = (e) => { if (e.target.id === 'importModal') closeImportModal(); };
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

@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Tasks — 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">
<script src="https://cdn.tailwindcss.com"></script>
<style>
* { font-family: 'Inter', sans-serif; }
body { background: #0a1628; margin: 0; }
.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); }
.task-card:hover { border-color: rgba(201,168,76,0.3); transform: translateY(-1px); }
.priority-high { background: #ef4444; }
.priority-normal { background: #c9a84c; }
.priority-low { background: #22c55e; }
</style>
</head>
<body>
<!-- Top Nav -->
<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-6">
<a href="/app/tasks" class="text-xl font-bold text-white tracking-tight">
<span class="text-[#c9a84c]">Deal</span>space
</a>
<select id="projectSwitcher" class="bg-[#0a1628] border border-white/[0.08] rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-[#c9a84c]">
<option value="">All Projects</option>
</select>
</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">
<!-- Sidebar -->
<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 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 text-[#94a3b8]" 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>
<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 Content -->
<main class="flex-1 p-8 max-w-5xl">
<!-- Greeting -->
<div class="mb-8">
<h1 id="greeting" class="text-2xl font-bold text-white mb-1"></h1>
<p class="text-[#94a3b8] text-sm">Here are your pending tasks.</p>
</div>
<!-- Task List -->
<div id="taskList" class="space-y-3">
<div class="text-[#94a3b8] text-sm">Loading tasks...</div>
</div>
<!-- Empty State -->
<div id="emptyState" class="hidden text-center py-20">
<div class="text-5xl mb-4">&#127881;</div>
<h2 class="text-xl font-semibold text-white mb-2">You're all caught up</h2>
<p class="text-[#94a3b8]">No tasks need your attention right now.</p>
</div>
</main>
</div>
<script>
// Auth check
const token = localStorage.getItem('ds_token');
if (!token) { window.location.href = '/app/login'; }
const user = JSON.parse(localStorage.getItem('ds_user') || '{}');
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.removeItem('ds_token');
localStorage.removeItem('ds_user');
window.location.href = '/app/login';
});
}
// Greeting
const hour = new Date().getHours();
const greetWord = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
document.getElementById('greeting').textContent = greetWord + ', ' + (user.name || 'there');
document.getElementById('userName').textContent = user.name || user.email || '';
if (user.is_super_admin) document.getElementById('adminLinks').classList.remove('hidden');
// Load tasks
async function loadTasks() {
try {
const res = await fetchAPI('/api/tasks');
const tasks = await res.json();
const list = document.getElementById('taskList');
const empty = document.getElementById('emptyState');
if (!tasks || tasks.length === 0) {
list.classList.add('hidden');
empty.classList.remove('hidden');
return;
}
list.innerHTML = tasks.map(t => {
const data = parseData(t.data_text);
const priority = data.priority || 'normal';
const title = data.title || t.summary || 'Untitled';
const ref = data.ref || '';
const due = data.due_date || '';
const status = data.status || 'open';
const projectName = t.project_id ? t.project_id.substring(0, 8) : '';
return `
<a href="/app/requests/${t.entry_id}" class="task-card block bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5 transition cursor-pointer">
<div class="flex items-center gap-3 mb-2">
<span class="w-2.5 h-2.5 rounded-full priority-${priority} shrink-0"></span>
${ref ? `<span class="text-xs font-mono text-[#94a3b8]">${ref}</span>` : ''}
<span class="text-white font-medium flex-1">${escapeHtml(title)}</span>
${due ? `<span class="text-xs text-[#94a3b8]">Due: ${due}</span>` : ''}
</div>
<div class="flex items-center gap-3 text-xs text-[#475569]">
<span class="capitalize px-2 py-0.5 rounded-full bg-white/[0.05] text-[#94a3b8]">${status}</span>
<span>${t.type || 'request'}</span>
</div>
</a>
`;
}).join('');
} catch (err) {
document.getElementById('taskList').innerHTML = '<div class="text-red-400 text-sm">Failed to load tasks.</div>';
}
}
function parseData(text) {
if (!text) return {};
try { return JSON.parse(text); } catch { return {}; }
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
loadTasks();
</script>
</body>
</html>

View File

@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — 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">
<script src="https://cdn.tailwindcss.com"></script>
<style>
* { font-family: 'Inter', sans-serif; }
body { background: #0a1628; }
.code-input { letter-spacing: 0.5em; text-align: center; font-size: 1.5rem; font-weight: 600; }
</style>
</head>
<body class="min-h-screen flex items-center justify-center">
<div class="w-full max-w-md px-6">
<!-- Logo -->
<div class="text-center mb-10">
<h1 class="text-3xl font-bold text-white tracking-tight">
<span class="text-[#c9a84c]">Deal</span>space
</h1>
<p class="text-[#94a3b8] mt-2 text-sm">Secure M&A deal management</p>
</div>
<!-- Step 1: Email -->
<div id="step-email" class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8">
<h2 class="text-xl font-semibold text-white mb-2">Sign in</h2>
<p class="text-[#94a3b8] text-sm mb-6">Enter your email to receive a login code.</p>
<div id="error-email" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
<form id="emailForm" class="space-y-5">
<div>
<label for="email" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Email</label>
<input type="email" id="email" name="email" required autocomplete="email" autofocus
placeholder="you@company.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] transition">
</div>
<button type="submit" id="emailBtn"
class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg transition disabled:opacity-50">
Send login code
</button>
</form>
</div>
<!-- Step 2: OTP Code -->
<div id="step-code" class="hidden bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8">
<h2 class="text-xl font-semibold text-white mb-2">Enter your code</h2>
<p class="text-[#94a3b8] text-sm mb-6">
We sent a 6-digit code to <span id="sent-email" class="text-white font-medium"></span>
</p>
<div id="error-code" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
<form id="codeForm" class="space-y-5">
<div>
<label for="code" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Login code</label>
<input type="text" id="code" name="code" required autocomplete="one-time-code"
maxlength="6" inputmode="numeric" pattern="[0-9]*"
placeholder="000000"
class="code-input w-full px-4 py-3 bg-[#0a1628] border border-white/[0.08] rounded-lg text-[#c9a84c] placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition">
</div>
<button type="submit" id="codeBtn"
class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg transition disabled:opacity-50">
Verify & sign in
</button>
</form>
<div class="mt-4 flex items-center justify-between">
<button id="backBtn" class="text-[#94a3b8] text-sm hover:text-white transition">
&larr; Use a different email
</button>
<button id="resendBtn" class="text-[#c9a84c] text-sm hover:text-[#b8973f] transition">
Resend code
</button>
</div>
</div>
<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>
<script>
// If already logged in, redirect
if (localStorage.getItem('ds_token')) {
window.location.href = '/app/tasks';
}
let currentEmail = '';
// Step 1: Send challenge
document.getElementById('emailForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('emailBtn');
const errorEl = document.getElementById('error-email');
btn.disabled = true;
btn.textContent = 'Sending code...';
errorEl.classList.add('hidden');
currentEmail = document.getElementById('email').value.trim().toLowerCase();
try {
const res = await fetch('/api/auth/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: currentEmail }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to send code');
// Show code step
document.getElementById('sent-email').textContent = currentEmail;
document.getElementById('step-email').classList.add('hidden');
document.getElementById('step-code').classList.remove('hidden');
document.getElementById('code').focus();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'Send login code';
}
});
// Step 2: Verify code
document.getElementById('codeForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('codeBtn');
const errorEl = document.getElementById('error-code');
btn.disabled = true;
btn.textContent = 'Verifying...';
errorEl.classList.add('hidden');
try {
const res = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: currentEmail,
code: document.getElementById('code').value.trim(),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Invalid or expired code');
localStorage.setItem('ds_token', data.token);
localStorage.setItem('ds_user', JSON.stringify(data.user));
// Everyone lands on /app/tasks — admin panel accessible from nav
window.location.href = '/app/tasks';
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Verify & sign in';
}
});
// Back button
document.getElementById('backBtn').addEventListener('click', () => {
document.getElementById('step-code').classList.add('hidden');
document.getElementById('step-email').classList.remove('hidden');
document.getElementById('code').value = '';
document.getElementById('error-code').classList.add('hidden');
document.getElementById('email').focus();
});
// Resend button
document.getElementById('resendBtn').addEventListener('click', async () => {
const btn = document.getElementById('resendBtn');
btn.disabled = true;
btn.textContent = 'Sending...';
try {
await fetch('/api/auth/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: currentEmail }),
});
btn.textContent = 'Code sent!';
setTimeout(() => {
btn.textContent = 'Resend code';
btn.disabled = false;
}, 3000);
} catch {
btn.textContent = 'Resend code';
btn.disabled = false;
}
});
// Auto-submit when 6 digits entered
document.getElementById('code').addEventListener('input', (e) => {
e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6);
if (e.target.value.length === 6) {
document.getElementById('codeForm').dispatchEvent(new Event('submit'));
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setup — 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">
<script src="https://cdn.tailwindcss.com"></script>
<style>
* { font-family: 'Inter', sans-serif; }
body { background: #0a1628; }
</style>
</head>
<body class="min-h-screen flex items-center justify-center">
<div class="w-full max-w-md px-6">
<!-- Logo -->
<div class="text-center mb-10">
<h1 class="text-3xl font-bold text-white tracking-tight">
<span class="text-[#c9a84c]">Deal</span>space
</h1>
<p class="text-[#94a3b8] mt-2 text-sm">First-time setup</p>
</div>
<!-- Setup Card -->
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8">
<h2 class="text-xl font-semibold text-white mb-2">Create admin account</h2>
<p class="text-[#94a3b8] text-sm mb-6">This will be the first administrator account for your Dealspace instance.</p>
<div id="error" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
<div id="success" class="hidden mb-4 p-3 bg-green-500/10 border border-green-500/20 rounded-lg text-green-400 text-sm"></div>
<form id="setupForm" class="space-y-5">
<div>
<label for="name" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Full name</label>
<input type="text" id="name" name="name" required autofocus
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] transition">
</div>
<div>
<label for="email" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Email</label>
<input type="email" id="email" name="email" required autocomplete="email"
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] transition">
</div>
<div>
<label for="password" class="block text-sm font-medium text-[#94a3b8] mb-1.5">Password</label>
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password"
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] transition">
<p class="text-xs text-[#475569] mt-1">Minimum 8 characters</p>
</div>
<button type="submit" id="submitBtn"
class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg transition disabled:opacity-50">
Create account
</button>
</form>
</div>
<p class="text-center text-[#475569] text-xs mt-8">&copy; 2026 Muskepo B.V. — Amsterdam</p>
</div>
<script>
document.getElementById('setupForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submitBtn');
const errorEl = document.getElementById('error');
const successEl = document.getElementById('success');
btn.disabled = true;
btn.textContent = 'Creating...';
errorEl.classList.add('hidden');
successEl.classList.add('hidden');
try {
const res = await fetch('/api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: document.getElementById('name').value,
email: document.getElementById('email').value,
password: document.getElementById('password').value,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Setup failed');
}
successEl.textContent = 'Admin account created! Redirecting to login...';
successEl.classList.remove('hidden');
setTimeout(() => { window.location.href = '/app/login'; }, 1500);
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Create account';
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,572 @@
<!DOCTYPE html>
<html lang="en" class="theme-light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FIN-042: Audited financials FY2024 — Dealspace</title>
<link rel="stylesheet" href="../static/themes.css">
<style>
/* Page-specific layout */
.page-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
padding: var(--space-lg);
}
/* Back link */
.back-link {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-sm);
color: var(--color-text-link);
text-decoration: none;
margin-bottom: var(--space-lg);
}
.back-link:hover {
color: var(--color-text-link-hover);
}
/* Request header */
.request-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-lg);
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border-primary);
margin-bottom: var(--space-lg);
}
.request-header-main {
flex: 1;
}
.request-ref-row {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-sm);
}
.request-ref {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-secondary);
font-family: var(--font-mono);
}
.request-title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 var(--space-sm) 0;
}
.request-meta {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.request-priority {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.priority-dot {
width: 0.75rem;
height: 0.75rem;
border-radius: var(--radius-full);
}
.priority-dot.high { background: var(--color-priority-high); }
.priority-dot.normal { background: var(--color-priority-normal); }
.priority-dot.low { background: var(--color-priority-low); }
.priority-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-primary);
}
/* Routing section */
.section-title {
font-size: var(--text-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
margin-bottom: var(--space-md);
}
.routing-section {
margin-bottom: var(--space-xl);
}
.routing-box {
padding: var(--space-lg);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
}
.routing-chain {
display: flex;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
margin-bottom: var(--space-lg);
}
.routing-step {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.routing-step.current {
padding: var(--space-xs) var(--space-sm);
background: var(--color-accent-light);
color: var(--color-accent);
font-weight: 600;
border-radius: var(--radius-md);
}
.routing-arrow {
color: var(--color-text-tertiary);
}
.routing-return {
font-size: var(--text-sm);
color: var(--color-text-secondary);
padding-top: var(--space-md);
border-top: 1px solid var(--color-border-secondary);
}
.routing-return strong {
color: var(--color-text-primary);
}
/* Actions bar */
.actions-bar {
display: flex;
gap: var(--space-md);
margin-top: var(--space-lg);
}
/* Thread section */
.thread-section {
margin-bottom: var(--space-xl);
}
.thread-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
/* Response section */
.response-section {
margin-bottom: var(--space-xl);
}
.response-box {
padding: var(--space-lg);
background: var(--color-card-bg);
border: 1px solid var(--color-card-border);
border-radius: var(--radius-lg);
}
.response-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-md);
padding-top: var(--space-md);
border-top: 1px solid var(--color-border-secondary);
}
.response-tools {
display: flex;
gap: var(--space-sm);
}
.response-submit {
display: flex;
gap: var(--space-sm);
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--text-lg);
}
.icon-btn:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
/* Global nav area */
.global-nav {
display: flex;
align-items: center;
gap: var(--space-lg);
margin-left: auto;
}
.inbox-link {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-inverse);
background: var(--color-accent);
border-radius: var(--radius-md);
text-decoration: none;
}
.user-menu {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.avatar {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--color-bg-tertiary);
}
.user-name {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-header-text);
}
/* Theme switcher (demo only) */
.theme-switcher {
position: fixed;
bottom: var(--space-lg);
right: var(--space-lg);
display: flex;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--color-card-bg);
border: 1px solid var(--color-card-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.theme-switcher button {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
color: var(--color-text-primary);
cursor: pointer;
}
.theme-switcher button:hover {
background: var(--color-bg-tertiary);
}
/* Two-column layout on larger screens */
@media (min-width: 1024px) {
.request-content {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-xl);
}
.request-main {
order: 1;
}
.request-sidebar {
order: 2;
}
}
/* Sidebar info card */
.info-card {
padding: var(--space-lg);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
margin-bottom: var(--space-lg);
}
.info-card-title {
font-size: var(--text-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
margin-bottom: var(--space-md);
}
.info-row {
display: flex;
justify-content: space-between;
padding: var(--space-sm) 0;
border-bottom: 1px solid var(--color-border-secondary);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.info-value {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-primary);
}
.info-value.overdue {
color: var(--color-error);
}
</style>
</head>
<body>
<div class="page-layout">
<!-- Global Header -->
<header class="global-header">
<div class="logo">Dealspace</div>
<select class="project-select" aria-label="Select project">
<option selected>Project Alpha — TargetCo Acquisition</option>
<option>Project Beta — MergeCo Deal</option>
</select>
<nav class="global-nav">
<a href="/inbox" class="inbox-link">
Inbox
<span class="badge">3</span>
</a>
<div class="user-menu">
<div class="avatar" role="img" aria-label="User avatar"></div>
<span class="user-name">S. Johnson</span>
</div>
</nav>
</header>
<!-- Main Content -->
<main class="main-content container">
<!-- Back navigation -->
<a href="/inbox" class="back-link">
← Back to Inbox
</a>
<!-- Request header -->
<header class="request-header">
<div class="request-header-main">
<div class="request-ref-row">
<span class="request-ref">FIN-042</span>
<span class="status-badge assigned">Assigned</span>
</div>
<h1 class="request-title">Audited financials FY2024</h1>
<p class="request-meta">Project Alpha • Finance • Due: March 15, 2026</p>
</div>
<div class="request-priority">
<span class="priority-dot high" aria-hidden="true"></span>
<span class="priority-label">High Priority</span>
</div>
</header>
<div class="request-content">
<div class="request-main">
<!-- Routing chain -->
<section class="routing-section">
<h2 class="section-title">Routing</h2>
<div class="routing-box">
<div class="routing-chain" role="navigation" aria-label="Request routing chain">
<span class="routing-step">
Acme Capital (Buyer)
</span>
<span class="routing-arrow" aria-hidden="true"></span>
<span class="routing-step">
J. Smith (IB)
</span>
<span class="routing-arrow" aria-hidden="true"></span>
<span class="routing-step">
M. Chen (CFO)
</span>
<span class="routing-arrow" aria-hidden="true"></span>
<span class="routing-step current">
★ You
</span>
</div>
<div class="routing-return">
When done, returns to: <strong>M. Chen (CFO)</strong>
</div>
<div class="actions-bar">
<button class="btn btn-primary">Mark Done</button>
<button class="btn btn-secondary">Forward</button>
</div>
</div>
</section>
<!-- Thread -->
<section class="thread-section">
<h2 class="section-title">Thread</h2>
<div class="thread-list">
<!-- Message 1: Original request -->
<article class="thread-message">
<header class="thread-message-header">
<span class="thread-message-author">J. Smith (IB)</span>
<time class="thread-message-time">Feb 25, 2026, 10:30 AM</time>
</header>
<div class="thread-message-body">
<p>Please provide audited financial statements for FY2024.</p>
<p>We need the following documents:</p>
<ul>
<li>Income statement (P&L)</li>
<li>Balance sheet</li>
<li>Cash flow statement</li>
<li>Auditor's opinion letter</li>
</ul>
<p>Please ensure these are the final audited versions, not draft.</p>
</div>
</article>
<!-- Message 2: CFO forwarding -->
<article class="thread-message">
<header class="thread-message-header">
<span class="thread-message-author">M. Chen (CFO)</span>
<time class="thread-message-time">Feb 26, 2026, 2:15 PM</time>
</header>
<div class="thread-message-body">
<p>@accountant Can you pull these from the ERP? The audit was finalized last month.</p>
<p>Attaching last year's format for reference so we maintain consistency.</p>
</div>
<a href="#" class="thread-message-attachment">
📎 fy2023-financials-format.xlsx
</a>
</article>
<!-- Message 3: Prior update -->
<article class="thread-message">
<header class="thread-message-header">
<span class="thread-message-author">S. Johnson (You)</span>
<time class="thread-message-time">Feb 27, 2026, 9:45 AM</time>
</header>
<div class="thread-message-body">
<p>On it. Just waiting for the auditor to send the final signed opinion letter. Should have everything by EOD Thursday.</p>
</div>
</article>
</div>
</section>
<!-- Response box -->
<section class="response-section">
<h2 class="section-title">Your Response</h2>
<div class="response-box">
<textarea
class="textarea"
placeholder="Write a message or upload files..."
aria-label="Response message"
></textarea>
<div class="response-actions">
<div class="response-tools">
<button class="icon-btn" title="Attach file" aria-label="Attach file">
📎
</button>
<button class="icon-btn" title="Format text" aria-label="Format text">
𝐁
</button>
</div>
<div class="response-submit">
<button class="btn btn-secondary">Save Draft</button>
<button class="btn btn-primary">Send & Mark Done</button>
</div>
</div>
</div>
</section>
</div>
<aside class="request-sidebar">
<!-- Request details card -->
<div class="info-card">
<h3 class="info-card-title">Details</h3>
<div class="info-row">
<span class="info-label">Status</span>
<span class="status-badge assigned">Assigned</span>
</div>
<div class="info-row">
<span class="info-label">Priority</span>
<span class="info-value">High</span>
</div>
<div class="info-row">
<span class="info-label">Due Date</span>
<span class="info-value overdue">Mar 15, 2026 (Overdue)</span>
</div>
<div class="info-row">
<span class="info-label">Workstream</span>
<span class="info-value">Finance</span>
</div>
<div class="info-row">
<span class="info-label">Created</span>
<span class="info-value">Feb 25, 2026</span>
</div>
<div class="info-row">
<span class="info-label">Assigned to</span>
<span class="info-value">S. Johnson</span>
</div>
</div>
<!-- Related requests card -->
<div class="info-card">
<h3 class="info-card-title">Related Requests</h3>
<div style="padding: var(--space-sm) 0;">
<a href="#" style="font-size: var(--text-sm); color: var(--color-text-link); text-decoration: none;">
FIN-041 — Audited financials FY2023
</a>
<span class="status-badge published" style="margin-left: var(--space-sm);">Published</span>
</div>
<div style="padding: var(--space-sm) 0;">
<a href="#" style="font-size: var(--text-sm); color: var(--color-text-link); text-decoration: none;">
FIN-045 — Revenue recognition policy
</a>
<span class="status-badge assigned" style="margin-left: var(--space-sm);">Assigned</span>
</div>
</div>
</aside>
</div>
</main>
</div>
<!-- Theme Switcher (demo/testing only) -->
<div class="theme-switcher" aria-label="Theme switcher">
<button onclick="document.documentElement.className='theme-light'">Light</button>
<button onclick="document.documentElement.className='theme-dark'">Dark</button>
<button onclick="document.documentElement.className='theme-contrast'">Contrast</button>
</div>
</body>
</html>

View File

@ -0,0 +1,258 @@
<!DOCTYPE html>
<html lang="en" class="theme-light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Tasks — Dealspace</title>
<link rel="stylesheet" href="../static/themes.css">
<style>
/* Page-specific layout */
.page-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
padding: var(--space-lg);
}
.inbox-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
}
.inbox-title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text-primary);
margin: 0;
}
.view-select {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
color: var(--color-text-primary);
background: var(--color-input-bg);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-md);
}
.task-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
/* Global nav area */
.global-nav {
display: flex;
align-items: center;
gap: var(--space-lg);
margin-left: auto;
}
.inbox-link {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-header-text);
background: var(--color-accent);
border-radius: var(--radius-md);
text-decoration: none;
}
.user-menu {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.avatar {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--color-bg-tertiary);
}
.user-name {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-header-text);
}
/* Theme switcher (demo only) */
.theme-switcher {
position: fixed;
bottom: var(--space-lg);
right: var(--space-lg);
display: flex;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--color-card-bg);
border: 1px solid var(--color-card-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.theme-switcher button {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
color: var(--color-text-primary);
cursor: pointer;
}
.theme-switcher button:hover {
background: var(--color-bg-tertiary);
}
</style>
</head>
<body>
<div class="page-layout">
<!-- Global Header -->
<header class="global-header">
<div class="logo">Dealspace</div>
<!-- Project selector (hidden for single-project workers) -->
<select class="project-select" aria-label="Select project">
<option selected>Project Alpha — TargetCo Acquisition</option>
<option>Project Beta — MergeCo Deal</option>
<option>Project Gamma — TechStartup Buyout</option>
</select>
<nav class="global-nav">
<a href="/inbox" class="inbox-link" aria-current="page">
Inbox
<span class="badge">3</span>
</a>
<div class="user-menu">
<div class="avatar" role="img" aria-label="User avatar"></div>
<span class="user-name">S. Johnson</span>
</div>
</nav>
</header>
<!-- Main Content -->
<main class="main-content container">
<div class="inbox-header">
<h1 class="inbox-title">My Tasks</h1>
<select class="view-select" aria-label="Filter tasks">
<option selected>All Tasks</option>
<option>Overdue</option>
<option>Due Today</option>
<option>Waiting</option>
</select>
</div>
<div class="task-list" role="list">
<!-- Task 1: High priority, overdue -->
<a href="/p/alpha/r/fin-042" class="task-card" role="listitem">
<div class="task-card-header">
<span class="task-card-priority high" aria-label="High priority"></span>
<span class="task-card-ref">FIN-042</span>
<span class="task-card-title">Audited financials FY2024</span>
<span class="task-card-due overdue">
<span aria-hidden="true">⚠️</span> Overdue: Mar 15
</span>
</div>
<div class="task-card-meta">
Project Alpha • Finance • From: J. Smith (IB)
</div>
<div class="task-card-preview">
"Please provide audited financial statements including P&L, balance sheet, and cash flow..."
</div>
</a>
<!-- Task 2: Normal priority, due today -->
<a href="/p/alpha/r/leg-018" class="task-card" role="listitem">
<div class="task-card-header">
<span class="task-card-priority normal" aria-label="Normal priority"></span>
<span class="task-card-ref">LEG-018</span>
<span class="task-card-title">Board meeting minutes 2025</span>
<span class="task-card-due today">Due: Today</span>
</div>
<div class="task-card-meta">
Project Alpha • Legal • From: M. Chen (CFO)
</div>
<div class="task-card-preview">
"Board approval documentation needed for the acquisition committee review..."
</div>
</a>
<!-- Task 3: Low priority, future -->
<a href="/p/beta/r/it-007" class="task-card" role="listitem">
<div class="task-card-header">
<span class="task-card-priority low" aria-label="Low priority"></span>
<span class="task-card-ref">IT-007</span>
<span class="task-card-title">Network architecture diagram</span>
<span class="task-card-due">Due: Mar 22</span>
</div>
<div class="task-card-meta">
Project Beta • IT • From: L. Park (IB)
</div>
<div class="task-card-preview">
"Provide a current network topology diagram showing all production systems..."
</div>
</a>
<!-- Task 4: Normal priority, future -->
<a href="/p/alpha/r/hr-003" class="task-card" role="listitem">
<div class="task-card-header">
<span class="task-card-priority normal" aria-label="Normal priority"></span>
<span class="task-card-ref">HR-003</span>
<span class="task-card-title">Employee headcount by department</span>
<span class="task-card-due">Due: Mar 25</span>
</div>
<div class="task-card-meta">
Project Alpha • HR • From: A. Williams (IB)
</div>
<div class="task-card-preview">
"Breakdown of full-time and contract employees by department, including salary bands..."
</div>
</a>
<!-- Task 5: High priority, future -->
<a href="/p/alpha/r/fin-045" class="task-card" role="listitem">
<div class="task-card-header">
<span class="task-card-priority high" aria-label="High priority"></span>
<span class="task-card-ref">FIN-045</span>
<span class="task-card-title">Revenue recognition policy</span>
<span class="task-card-due">Due: Mar 18</span>
</div>
<div class="task-card-meta">
Project Alpha • Finance • From: J. Smith (IB)
</div>
<div class="task-card-preview">
"Document your revenue recognition policy and any changes in the past 3 years..."
</div>
</a>
</div>
</main>
</div>
<!-- Theme Switcher (demo/testing only) -->
<div class="theme-switcher" aria-label="Theme switcher">
<button onclick="document.documentElement.className='theme-light'">Light</button>
<button onclick="document.documentElement.className='theme-dark'">Dark</button>
<button onclick="document.documentElement.className='theme-contrast'">Contrast</button>
</div>
<!-- Empty State (hidden - shown when no tasks) -->
<!--
<div class="empty-state">
<div class="empty-state-icon"></div>
<h2 class="empty-state-title">You're all caught up</h2>
<p class="empty-state-text">No tasks need your attention right now.</p>
</div>
-->
</body>
</html>