leedab 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -80,24 +80,6 @@
80
80
  letter-spacing: -0.01em;
81
81
  }
82
82
 
83
- .header-badge {
84
- display: flex;
85
- align-items: center;
86
- gap: 5px;
87
- padding: 3px 8px;
88
- background: rgba(34, 197, 94, 0.1);
89
- border-radius: 20px;
90
- font-size: 11px;
91
- color: var(--green);
92
- font-weight: 500;
93
- }
94
-
95
- .header-badge-dot {
96
- width: 6px;
97
- height: 6px;
98
- border-radius: 50%;
99
- background: var(--green);
100
- }
101
83
 
102
84
  .header-nav {
103
85
  display: flex;
@@ -533,18 +515,14 @@
533
515
  <body>
534
516
  <div class="header">
535
517
  <div class="header-left">
536
- <span class="header-title">LeedAB</span>
537
- <div class="header-badge">
538
- <div class="header-badge-dot"></div>
539
- On
540
- </div>
518
+ <a href="/" class="header-title" style="text-decoration:none;color:inherit">LeedAB</a>
541
519
  </div>
542
520
  <div class="header-nav">
543
- <a href="/console.html">
521
+ <a href="/admin">
544
522
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
545
- <rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
523
+ <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
546
524
  </svg>
547
- Console
525
+ Admin
548
526
  </a>
549
527
  <a href="/sessions.html">
550
528
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -20,17 +20,9 @@
20
20
 
21
21
  <div class="page-header">
22
22
  <div class="page-header-left">
23
- <span class="page-header-title">Sessions</span>
23
+ <a href="/" class="page-header-title" style="text-decoration:none;color:inherit">LeedAB</a>
24
24
  </div>
25
25
  <div class="page-nav">
26
- <a href="/">
27
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4 20-7Z"/><path d="M22 2 11 13"/></svg>
28
- Chat
29
- </a>
30
- <a href="/console.html">
31
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
32
- Console
33
- </a>
34
26
  <button class="theme-btn" onclick="toggleTheme()" title="Toggle theme">
35
27
  <svg id="theme-icon-sun" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
36
28
  <svg id="theme-icon-moon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
@@ -83,6 +75,16 @@
83
75
  color: var(--text-dim);
84
76
  }
85
77
 
78
+ .session-summary {
79
+ color: var(--text-secondary) !important;
80
+ font-size: 13px !important;
81
+ margin-bottom: 2px;
82
+ overflow: hidden;
83
+ text-overflow: ellipsis;
84
+ white-space: nowrap;
85
+ max-width: 500px;
86
+ }
87
+
86
88
  .session-meta {
87
89
  font-size: 12px;
88
90
  color: var(--text-faint);
@@ -140,28 +142,22 @@
140
142
  const date = s.updatedAt ? new Date(s.updatedAt).toLocaleDateString("en-US", {
141
143
  month: "short", day: "numeric", hour: "numeric", minute: "2-digit"
142
144
  }) : "";
143
- // Derive channel and label from key like "agent:main:telegram:direct:123"
145
+ // Derive channel from key like "agent:main:telegram:direct:123"
144
146
  const parts = (s.key || "").split(":");
145
147
  let channel = "console";
146
- let label = s.key || s.sessionId || "unknown";
147
- if (parts[2] === "telegram") {
148
- channel = "telegram";
149
- label = parts.slice(2).join(":");
150
- } else if (parts[2] === "cron") {
151
- channel = "cron";
152
- label = parts.slice(2).join(":");
153
- } else if (parts[2] === "subagent") {
154
- channel = "subagent";
155
- label = parts.slice(2).join(":");
156
- } else if (parts.length >= 3) {
157
- label = parts.slice(2).join(":");
158
- }
148
+ if (parts[2] === "telegram") channel = "telegram";
149
+ else if (parts[2] === "cron") channel = "cron";
150
+ else if (parts[2] === "subagent") channel = "subagent";
151
+
152
+ const title = s.senderName || channel;
153
+ const summary = s.summary ? escapeHtml(s.summary) : "";
159
154
  const tokens = s.totalTokens != null ? `${(s.totalTokens / 1000).toFixed(1)}k tokens` : "";
160
155
  const sid = s.sessionId || s.key || "";
161
156
  return `
162
157
  <a class="session-item" href="/?session=${encodeURIComponent(sid)}">
163
158
  <div class="session-info">
164
- <h3>${escapeHtml(label)}</h3>
159
+ <h3>${escapeHtml(title)}</h3>
160
+ ${summary ? `<p class="session-summary">${summary}</p>` : ""}
165
161
  <p>${escapeHtml(s.model || "")} ${tokens ? "· " + tokens : ""}</p>
166
162
  </div>
167
163
  <div class="session-meta">
package/dist/license.d.ts CHANGED
@@ -6,6 +6,9 @@ export interface LicenseInfo {
6
6
  seatsUsed: number;
7
7
  maxSeats: number;
8
8
  validatedAt: string;
9
+ email?: string;
10
+ name?: string;
11
+ orgName?: string;
9
12
  }
10
13
  /**
11
14
  * Validate a license key against the API.
package/dist/license.js CHANGED
@@ -2,8 +2,10 @@ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { STATE_DIR } from "./paths.js";
4
4
  const LICENSE_PATH = resolve(STATE_DIR, "license.json");
5
- const VERIFY_URL = "https://api.leedab.com/api/v1/licensing/verify";
5
+ const API_URL = process.env.LEEDAB_API_URL || "https://api.leedab.com/api/v1";
6
+ const VERIFY_URL = `${API_URL}/licensing/verify`;
6
7
  const REVALIDATE_DAYS = 7;
8
+ const REVALIDATE_DAYS_TRIAL = 1;
7
9
  /**
8
10
  * Validate a license key against the API.
9
11
  */
@@ -14,6 +16,7 @@ export async function validateLicenseKey(key) {
14
16
  Authorization: `Bearer ${key}`,
15
17
  "Content-Type": "application/json",
16
18
  },
19
+ signal: AbortSignal.timeout(8000),
17
20
  });
18
21
  if (!res.ok) {
19
22
  return {
@@ -35,6 +38,9 @@ export async function validateLicenseKey(key) {
35
38
  seatsUsed: data.seats_used ?? 0,
36
39
  maxSeats: data.max_seats ?? 1,
37
40
  validatedAt: new Date().toISOString(),
41
+ email: data.email ?? undefined,
42
+ name: data.name ?? undefined,
43
+ orgName: data.org_name ?? undefined,
38
44
  };
39
45
  }
40
46
  /**
@@ -63,7 +69,8 @@ function isStale(license) {
63
69
  const validated = new Date(license.validatedAt).getTime();
64
70
  const now = Date.now();
65
71
  const daysSince = (now - validated) / (1000 * 60 * 60 * 24);
66
- return daysSince > REVALIDATE_DAYS;
72
+ const maxDays = license.status === "trialing" ? REVALIDATE_DAYS_TRIAL : REVALIDATE_DAYS;
73
+ return daysSince > maxDays;
67
74
  }
68
75
  /**
69
76
  * Ensure we have a valid license. Returns the license if valid.
@@ -74,8 +81,10 @@ export async function ensureLicense() {
74
81
  const cached = await loadLicense();
75
82
  if (!cached)
76
83
  return null;
84
+ // Re-validate if fresh but missing fields added after initial cache (one-time migration).
85
+ const needsMigration = cached.valid && !cached.email;
77
86
  // If cached and fresh, trust it
78
- if (cached.valid && !isStale(cached)) {
87
+ if (cached.valid && !isStale(cached) && !needsMigration) {
79
88
  return cached;
80
89
  }
81
90
  // Revalidate
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Local team store. This is the single source of truth for who can reach the
3
+ * agent and what they can run. No cloud membership is required.
4
+ *
5
+ * Shape persisted at ~/.leedab/team-permissions.json:
6
+ * {
7
+ * "version": 2,
8
+ * "members": {
9
+ * "<uuid>": {
10
+ * "name": "Jane",
11
+ * "email": "jane@example.com", // optional
12
+ * "role": "member",
13
+ * "joined_at": "2026-04-13T...",
14
+ * "handles": { "telegram": "6549960466" },
15
+ * "allowedWorkflows": [],
16
+ * "allowedChannels": ["telegram"]
17
+ * }
18
+ * }
19
+ * }
20
+ */
21
+ export declare const OVERLAY_PATH: string;
22
+ export type ChannelName = "whatsapp" | "telegram" | "teams" | "dashboard";
23
+ export type MemberRole = "owner" | "admin" | "member";
24
+ export interface MemberHandles {
25
+ whatsapp?: string;
26
+ telegram?: string;
27
+ teams?: string;
28
+ }
29
+ export interface MemberPermissions {
30
+ name: string;
31
+ email?: string;
32
+ role: MemberRole;
33
+ joined_at: string;
34
+ handles: MemberHandles;
35
+ allowedWorkflows: string[];
36
+ allowedChannels: ChannelName[];
37
+ }
38
+ export interface Overlay {
39
+ version: 2;
40
+ members: Record<string, MemberPermissions>;
41
+ }
42
+ export declare const EMPTY_PERMISSIONS: Omit<MemberPermissions, "name" | "role" | "joined_at">;
43
+ export declare function readOverlay(): Promise<Overlay>;
44
+ /** Atomic write: write a sibling temp file then rename. */
45
+ export declare function writeOverlay(overlay: Overlay): Promise<void>;
46
+ export declare function getMemberPermissions(memberId: string): Promise<MemberPermissions | null>;
47
+ /** Update only the permission fields (handles/workflows/channels) for an existing member. */
48
+ export declare function setMemberPermissions(memberId: string, perms: Partial<Pick<MemberPermissions, "handles" | "allowedWorkflows" | "allowedChannels">>): Promise<MemberPermissions | null>;
49
+ /** Add a new member and return their full record including generated id. */
50
+ export declare function addLocalMember(data: {
51
+ name: string;
52
+ email?: string;
53
+ role: MemberRole;
54
+ }): Promise<{
55
+ id: string;
56
+ } & MemberPermissions>;
57
+ /** Remove a member. Returns true if the member existed. */
58
+ export declare function removeLocalMember(id: string): Promise<boolean>;
59
+ /** Update a member's role. */
60
+ export declare function setMemberRole(id: string, role: MemberRole): Promise<boolean>;
61
+ /** Update a member's identity fields (name, email). */
62
+ export declare function updateMemberIdentity(id: string, fields: {
63
+ name?: string;
64
+ email?: string;
65
+ }): Promise<void>;
@@ -0,0 +1,138 @@
1
+ import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { resolve, dirname } from "node:path";
4
+ import { STATE_DIR } from "../paths.js";
5
+ /**
6
+ * Local team store. This is the single source of truth for who can reach the
7
+ * agent and what they can run. No cloud membership is required.
8
+ *
9
+ * Shape persisted at ~/.leedab/team-permissions.json:
10
+ * {
11
+ * "version": 2,
12
+ * "members": {
13
+ * "<uuid>": {
14
+ * "name": "Jane",
15
+ * "email": "jane@example.com", // optional
16
+ * "role": "member",
17
+ * "joined_at": "2026-04-13T...",
18
+ * "handles": { "telegram": "6549960466" },
19
+ * "allowedWorkflows": [],
20
+ * "allowedChannels": ["telegram"]
21
+ * }
22
+ * }
23
+ * }
24
+ */
25
+ export const OVERLAY_PATH = resolve(STATE_DIR, "team-permissions.json");
26
+ export const EMPTY_PERMISSIONS = {
27
+ handles: {},
28
+ allowedWorkflows: [],
29
+ allowedChannels: [],
30
+ };
31
+ function emptyOverlay() {
32
+ return { version: 2, members: {} };
33
+ }
34
+ export async function readOverlay() {
35
+ try {
36
+ const raw = await readFile(OVERLAY_PATH, "utf-8");
37
+ const parsed = JSON.parse(raw);
38
+ if (!parsed || typeof parsed !== "object" || !parsed.members) {
39
+ return emptyOverlay();
40
+ }
41
+ // Migrate v1 entries (no name/role/joined_at) gracefully
42
+ const members = {};
43
+ for (const [id, entry] of Object.entries(parsed.members)) {
44
+ members[id] = {
45
+ name: entry.name ?? entry.username ?? id.slice(0, 8),
46
+ email: entry.email,
47
+ role: entry.role ?? "member",
48
+ joined_at: entry.joined_at ?? new Date().toISOString(),
49
+ handles: entry.handles ?? {},
50
+ allowedWorkflows: entry.allowedWorkflows ?? [],
51
+ allowedChannels: entry.allowedChannels ?? [],
52
+ };
53
+ }
54
+ return { version: 2, members };
55
+ }
56
+ catch {
57
+ return emptyOverlay();
58
+ }
59
+ }
60
+ /** Atomic write: write a sibling temp file then rename. */
61
+ export async function writeOverlay(overlay) {
62
+ await mkdir(dirname(OVERLAY_PATH), { recursive: true });
63
+ const tmp = `${OVERLAY_PATH}.${process.pid}.tmp`;
64
+ await writeFile(tmp, JSON.stringify(overlay, null, 2) + "\n", "utf-8");
65
+ await rename(tmp, OVERLAY_PATH);
66
+ }
67
+ export async function getMemberPermissions(memberId) {
68
+ const overlay = await readOverlay();
69
+ return overlay.members[memberId] ?? null;
70
+ }
71
+ /** Update only the permission fields (handles/workflows/channels) for an existing member. */
72
+ export async function setMemberPermissions(memberId, perms) {
73
+ const overlay = await readOverlay();
74
+ const existing = overlay.members[memberId];
75
+ if (!existing)
76
+ return null;
77
+ const updated = {
78
+ ...existing,
79
+ handles: perms.handles !== undefined
80
+ ? { ...existing.handles, ...perms.handles }
81
+ : existing.handles,
82
+ allowedWorkflows: perms.allowedWorkflows !== undefined
83
+ ? [...perms.allowedWorkflows]
84
+ : existing.allowedWorkflows,
85
+ allowedChannels: perms.allowedChannels !== undefined
86
+ ? [...perms.allowedChannels]
87
+ : existing.allowedChannels,
88
+ };
89
+ overlay.members[memberId] = updated;
90
+ await writeOverlay(overlay);
91
+ return updated;
92
+ }
93
+ /** Add a new member and return their full record including generated id. */
94
+ export async function addLocalMember(data) {
95
+ const overlay = await readOverlay();
96
+ const id = randomUUID();
97
+ const member = {
98
+ name: data.name.trim(),
99
+ email: data.email?.trim().toLowerCase() || undefined,
100
+ role: data.role,
101
+ joined_at: new Date().toISOString(),
102
+ handles: {},
103
+ allowedWorkflows: [],
104
+ allowedChannels: [],
105
+ };
106
+ overlay.members[id] = member;
107
+ await writeOverlay(overlay);
108
+ return { id, ...member };
109
+ }
110
+ /** Remove a member. Returns true if the member existed. */
111
+ export async function removeLocalMember(id) {
112
+ const overlay = await readOverlay();
113
+ if (!overlay.members[id])
114
+ return false;
115
+ delete overlay.members[id];
116
+ await writeOverlay(overlay);
117
+ return true;
118
+ }
119
+ /** Update a member's role. */
120
+ export async function setMemberRole(id, role) {
121
+ const overlay = await readOverlay();
122
+ if (!overlay.members[id])
123
+ return false;
124
+ overlay.members[id].role = role;
125
+ await writeOverlay(overlay);
126
+ return true;
127
+ }
128
+ /** Update a member's identity fields (name, email). */
129
+ export async function updateMemberIdentity(id, fields) {
130
+ const overlay = await readOverlay();
131
+ if (!overlay.members[id])
132
+ return;
133
+ if (fields.name)
134
+ overlay.members[id].name = fields.name;
135
+ if (fields.email !== undefined)
136
+ overlay.members[id].email = fields.email;
137
+ await writeOverlay(overlay);
138
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Rebuild per-channel allowlists in openclaw.json from the team overlay.
3
+ * The overlay is the source of truth; this writer is idempotent.
4
+ */
5
+ export declare function syncAllowlists(options?: {
6
+ restart?: boolean;
7
+ }): Promise<void>;
@@ -0,0 +1,62 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { STATE_DIR } from "../paths.js";
6
+ import { resolveOpenClawBin, openclawEnv } from "../openclaw.js";
7
+ import { readOverlay } from "./permissions.js";
8
+ const execFileAsync = promisify(execFile);
9
+ const MESSAGING_CHANNELS = ["whatsapp", "telegram", "teams"];
10
+ /**
11
+ * Rebuild per-channel allowlists in openclaw.json from the team overlay.
12
+ * The overlay is the source of truth; this writer is idempotent.
13
+ */
14
+ export async function syncAllowlists(options = {}) {
15
+ const configPath = resolve(STATE_DIR, "openclaw.json");
16
+ let config;
17
+ try {
18
+ const raw = await readFile(configPath, "utf-8");
19
+ config = JSON.parse(raw);
20
+ }
21
+ catch {
22
+ // No config yet — nothing to sync into.
23
+ return;
24
+ }
25
+ if (!config.channels)
26
+ config.channels = {};
27
+ const overlay = await readOverlay();
28
+ for (const channel of MESSAGING_CHANNELS) {
29
+ const handles = [];
30
+ for (const perms of Object.values(overlay.members)) {
31
+ if (!perms.allowedChannels?.includes(channel))
32
+ continue;
33
+ const handle = perms.handles[channel];
34
+ if (handle && handle.trim())
35
+ handles.push(handle.trim());
36
+ }
37
+ if (!config.channels[channel]) {
38
+ // Channel not registered locally; skip — we don't want to invent it.
39
+ continue;
40
+ }
41
+ const deduped = Array.from(new Set(handles));
42
+ config.channels[channel].allowFrom = deduped;
43
+ // allowlist with an empty list is invalid in openclaw ≥2026.3.31;
44
+ // an empty set is semantically equivalent to blocking anyway.
45
+ config.channels[channel].dmPolicy = deduped.length > 0 ? "allowlist" : "disabled";
46
+ }
47
+ await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
48
+ if (options.restart !== false) {
49
+ await restartGatewayQuietly();
50
+ }
51
+ }
52
+ async function restartGatewayQuietly() {
53
+ try {
54
+ const bin = resolveOpenClawBin();
55
+ const env = openclawEnv(STATE_DIR);
56
+ await execFileAsync(bin, ["gateway", "health"], { env, timeout: 3000 });
57
+ await execFileAsync(bin, ["gateway", "restart"], { env, timeout: 10000 });
58
+ }
59
+ catch {
60
+ // Gateway not running — nothing to restart.
61
+ }
62
+ }
package/dist/team.d.ts CHANGED
@@ -1,17 +1,20 @@
1
- export type Role = "admin" | "member" | "owner";
1
+ import { type ChannelName, type MemberHandles, type MemberRole } from "./team/permissions.js";
2
+ export type Role = MemberRole;
2
3
  export interface TeamMember {
3
4
  id: string;
4
- user_id: number;
5
- email: string;
6
- username: string;
5
+ name: string;
6
+ email?: string;
7
7
  role: Role;
8
8
  joined_at: string;
9
+ handles: MemberHandles;
10
+ allowedWorkflows: string[];
11
+ allowedChannels: ChannelName[];
9
12
  }
10
13
  export declare function loadTeam(): Promise<TeamMember[]>;
11
- export declare function addMember(member: {
14
+ export declare function addMember(data: {
12
15
  name: string;
13
- email: string;
16
+ email?: string;
14
17
  role: Role;
15
18
  }): Promise<TeamMember>;
16
- export declare function removeMember(memberId: string): Promise<boolean>;
19
+ export declare function removeMember(id: string): Promise<boolean>;
17
20
  export declare function updateRole(memberId: string, role: Role): Promise<boolean>;
package/dist/team.js CHANGED
@@ -1,75 +1,69 @@
1
- import { loadLicense } from "./license.js";
2
- const API_URL = "https://api.leedab.com/api/v1";
3
- /**
4
- * Get the license key for API auth.
5
- */
6
- async function getAuthHeader() {
7
- const license = await loadLicense();
8
- if (!license?.key) {
9
- throw new Error("No license key found. Run `leedab onboard` first.");
10
- }
11
- return {
12
- Authorization: `Bearer ${license.key}`,
13
- "Content-Type": "application/json",
14
- };
15
- }
16
- /**
17
- * Get the user's organization slug.
18
- */
19
- async function getOrgSlug(headers) {
20
- const res = await fetch(`${API_URL}/organizations`, { headers });
21
- if (!res.ok) {
22
- throw new Error("Failed to fetch organizations.");
23
- }
24
- const data = await res.json();
25
- if (!data.length) {
26
- throw new Error("No organization found. Create one in the dashboard first.");
27
- }
28
- return data[0].slug;
29
- }
1
+ import { userInfo } from "node:os";
2
+ import { readOverlay, addLocalMember, removeLocalMember, setMemberRole, updateMemberIdentity, } from "./team/permissions.js";
3
+ import { ensureLicense } from "./license.js";
30
4
  export async function loadTeam() {
31
- const headers = await getAuthHeader();
32
- const slug = await getOrgSlug(headers);
33
- const res = await fetch(`${API_URL}/organizations/${slug}/members/`, { headers });
34
- if (!res.ok) {
35
- throw new Error("Failed to fetch team members.");
5
+ const overlay = await readOverlay();
6
+ const entries = Object.entries(overlay.members);
7
+ // ensureLicense re-validates if email/name fields are missing (one-time migration).
8
+ const license = await ensureLicense().catch(() => null);
9
+ const licenseEmail = license?.email;
10
+ const licenseName = license?.name || licenseEmail?.split("@")[0];
11
+ // Seed the install owner on first use so they always appear in the list.
12
+ if (entries.length === 0) {
13
+ const owner = await addLocalMember({
14
+ name: licenseName || userInfo().username,
15
+ email: licenseEmail,
16
+ role: "owner",
17
+ });
18
+ return [{
19
+ id: owner.id,
20
+ name: owner.name,
21
+ email: owner.email,
22
+ role: owner.role,
23
+ joined_at: owner.joined_at,
24
+ handles: owner.handles,
25
+ allowedWorkflows: owner.allowedWorkflows,
26
+ allowedChannels: owner.allowedChannels,
27
+ }];
36
28
  }
37
- const data = await res.json();
38
- return data.members;
39
- }
40
- export async function addMember(member) {
41
- const headers = await getAuthHeader();
42
- const slug = await getOrgSlug(headers);
43
- const res = await fetch(`${API_URL}/organizations/${slug}/invites/`, {
44
- method: "POST",
45
- headers,
46
- body: JSON.stringify({
47
- email: member.email,
48
- role: member.role,
49
- }),
50
- });
51
- if (!res.ok) {
52
- const err = await res.json().catch(() => ({}));
53
- throw new Error(err.error || "Failed to add team member.");
29
+ // Auto-update owner entry if it was seeded with the OS username before
30
+ // license name/email were available.
31
+ if (licenseName && licenseName !== userInfo().username) {
32
+ const ownerEntry = entries.find(([, m]) => m.role === "owner" && m.name === userInfo().username);
33
+ if (ownerEntry) {
34
+ await updateMemberIdentity(ownerEntry[0], { name: licenseName, email: licenseEmail });
35
+ ownerEntry[1].name = licenseName;
36
+ if (licenseEmail)
37
+ ownerEntry[1].email = licenseEmail;
38
+ }
54
39
  }
55
- return await res.json();
40
+ return entries.map(([id, m]) => ({
41
+ id,
42
+ name: m.name,
43
+ email: m.email,
44
+ role: m.role,
45
+ joined_at: m.joined_at,
46
+ handles: { ...m.handles },
47
+ allowedWorkflows: [...m.allowedWorkflows],
48
+ allowedChannels: [...m.allowedChannels],
49
+ }));
50
+ }
51
+ export async function addMember(data) {
52
+ const result = await addLocalMember(data);
53
+ return {
54
+ id: result.id,
55
+ name: result.name,
56
+ email: result.email,
57
+ role: result.role,
58
+ joined_at: result.joined_at,
59
+ handles: result.handles,
60
+ allowedWorkflows: result.allowedWorkflows,
61
+ allowedChannels: result.allowedChannels,
62
+ };
56
63
  }
57
- export async function removeMember(memberId) {
58
- const headers = await getAuthHeader();
59
- const slug = await getOrgSlug(headers);
60
- const res = await fetch(`${API_URL}/organizations/${slug}/members/${memberId}/`, {
61
- method: "DELETE",
62
- headers,
63
- });
64
- return res.ok;
64
+ export async function removeMember(id) {
65
+ return removeLocalMember(id);
65
66
  }
66
67
  export async function updateRole(memberId, role) {
67
- const headers = await getAuthHeader();
68
- const slug = await getOrgSlug(headers);
69
- const res = await fetch(`${API_URL}/organizations/${slug}/members/${memberId}/`, {
70
- method: "PUT",
71
- headers,
72
- body: JSON.stringify({ role }),
73
- });
74
- return res.ok;
68
+ return setMemberRole(memberId, role);
75
69
  }
@@ -22,6 +22,7 @@ When you write a Python script to generate a document from a template, save thes
22
22
 
23
23
  ---
24
24
 
25
+ <a id="payroll_calculator"></a>
25
26
  ## 1. Payroll Calculator
26
27
 
27
28
  **Trigger:** User says "run payroll", "calculate payroll", "process payroll for [period]", or uploads attendance/salary files.
@@ -45,6 +46,7 @@ When you write a Python script to generate a document from a template, save thes
45
46
 
46
47
  ---
47
48
 
49
+ <a id="credit_note_generator"></a>
48
50
  ## 2. Credit Note Generator
49
51
 
50
52
  **Trigger:** User says "generate credit notes", "create credit notes for [period]", or uploads deduction data.
@@ -72,6 +74,7 @@ When you write a Python script to generate a document from a template, save thes
72
74
 
73
75
  ---
74
76
 
77
+ <a id="deduction_reconciler"></a>
75
78
  ## 3. Deduction Reconciler
76
79
 
77
80
  **Trigger:** User says "reconcile deductions", "check deductions", or uploads credit notes + payout sheets.
@@ -96,6 +99,7 @@ When you write a Python script to generate a document from a template, save thes
96
99
 
97
100
  ---
98
101
 
102
+ <a id="attendance_importer"></a>
99
103
  ## 4. Attendance Importer
100
104
 
101
105
  **Trigger:** User says "import attendance", "process attendance for [period]", or uploads client attendance sheets.
@@ -121,6 +125,7 @@ When you write a Python script to generate a document from a template, save thes
121
125
 
122
126
  ---
123
127
 
128
+ <a id="driver_clearance"></a>
124
129
  ## 5. Driver Clearance
125
130
 
126
131
  **Trigger:** User says "process clearance", "driver offboarding", or uploads clearance data.