This commit is contained in:
@@ -98,4 +98,69 @@ export function mountAdminApi(
|
||||
deleteUserFolder(userFolderRoot, id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ── Local organizations (admin) ───────────────────────────────────────
|
||||
// A provider-agnostic 'org' visibility scope for local accounts. See
|
||||
// docs/superpowers/plans/2026-06-09-local-orgs.md.
|
||||
|
||||
app.get('/api/admin/orgs', guard, (_req: Request, res: Response) => {
|
||||
if (!authActive) { res.json([]); return; }
|
||||
const orgs = repo.listLocalOrgs().map(o => ({ ...o, members: repo.listOrgMembers(o.id) }));
|
||||
res.json(orgs);
|
||||
});
|
||||
|
||||
app.post('/api/admin/orgs', guard, (req: Request, res: Response) => {
|
||||
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
|
||||
const { name } = (req.body ?? {}) as { name?: string };
|
||||
if (typeof name !== 'string' || !name.trim()) {
|
||||
res.status(400).json({ error: 'name is required' });
|
||||
return;
|
||||
}
|
||||
const createdBy = (req.user as { id?: string } | undefined)?.id ?? null;
|
||||
res.status(201).json(repo.createLocalOrg(name.trim(), createdBy));
|
||||
});
|
||||
|
||||
app.patch('/api/admin/orgs/:id', guard, (req: Request, res: Response) => {
|
||||
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
|
||||
const { id } = req.params;
|
||||
const { name } = (req.body ?? {}) as { name?: string };
|
||||
if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; }
|
||||
if (typeof name !== 'string' || !name.trim()) { res.status(400).json({ error: 'name is required' }); return; }
|
||||
repo.renameLocalOrg(id, name.trim());
|
||||
res.json(repo.getLocalOrg(id));
|
||||
});
|
||||
|
||||
app.delete('/api/admin/orgs/:id', guard, (req: Request, res: Response) => {
|
||||
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
|
||||
const { id } = req.params;
|
||||
if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; }
|
||||
// repo downgrades org-scoped tasks/schedules/jobs/notes to private first.
|
||||
repo.deleteLocalOrg(id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.post('/api/admin/orgs/:id/members', guard, (req: Request, res: Response) => {
|
||||
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
|
||||
const { id } = req.params;
|
||||
const { userId, role } = (req.body ?? {}) as { userId?: string; role?: string };
|
||||
if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; }
|
||||
if (typeof userId !== 'string' || !repo.getUserById(userId)) {
|
||||
res.status(400).json({ error: 'a valid userId is required' });
|
||||
return;
|
||||
}
|
||||
repo.addOrgMember(id, userId, role === 'owner' ? 'owner' : 'member');
|
||||
// The added user's session orgIds change — drop their sessions so the next
|
||||
// request re-derives membership (and 'org'-visible resources appear).
|
||||
repo.deleteSessionsByUserId(userId);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.delete('/api/admin/orgs/:id/members/:userId', guard, (req: Request, res: Response) => {
|
||||
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
|
||||
const { id, userId } = req.params;
|
||||
if (!repo.getLocalOrg(id)) { res.status(404).json({ error: 'Org not found' }); return; }
|
||||
repo.removeOrgMember(id, userId);
|
||||
repo.deleteSessionsByUserId(userId);
|
||||
res.status(204).end();
|
||||
});
|
||||
}
|
||||
|
||||
+14
-4
@@ -81,6 +81,17 @@ export function isLocalEnabled(authConfig: AuthConfig): boolean {
|
||||
return authConfig.local?.enabled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The org ids a user belongs to, for session.orgIds. Union of Gitea orgs
|
||||
* (from OAuth login) and local orgs (membership). buildVisibilityWhere is
|
||||
* provider-agnostic, so 'org' visibility works for either once these are set.
|
||||
*/
|
||||
export function resolveOrgIds(repo: Repository, userId: string): string[] {
|
||||
const gitea = repo.listUserGiteaOrgs(userId).map(o => o.orgId);
|
||||
const local = repo.listUserLocalOrgs(userId).map(o => o.orgId);
|
||||
return [...new Set([...gitea, ...local])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-service password change for the authenticated local user. Mount behind
|
||||
* requireAuth + a JSON body parser. Requires the CURRENT password (so a
|
||||
@@ -497,7 +508,7 @@ function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
|
||||
if (updated) user = updated;
|
||||
}
|
||||
await fetchGiteaOrgsForUser(repo, user.id, baseUrl, accessToken);
|
||||
const orgIds = repo.listUserGiteaOrgs(user.id).map(o => o.orgId);
|
||||
const orgIds = resolveOrgIds(repo, user.id);
|
||||
const sessionUser: Express.User = {
|
||||
...user,
|
||||
orgIds,
|
||||
@@ -589,7 +600,7 @@ function createAuthRouter(
|
||||
|
||||
const toExpressUser = (u: User): Express.User => ({
|
||||
...u,
|
||||
orgIds: [],
|
||||
orgIds: resolveOrgIds(repo, u.id),
|
||||
defaultVisibility: u.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: u.defaultVisibilityOrgId ?? null,
|
||||
});
|
||||
@@ -717,10 +728,9 @@ export function setupAuth(
|
||||
try {
|
||||
const baseUser = repo.getUserById(id);
|
||||
if (!baseUser) { done(null, false); return; }
|
||||
const orgs = repo.listUserGiteaOrgs(id);
|
||||
const enriched: Express.User = {
|
||||
...baseUser,
|
||||
orgIds: orgs.map(o => o.orgId),
|
||||
orgIds: resolveOrgIds(repo, id),
|
||||
defaultVisibility: baseUser.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: baseUser.defaultVisibilityOrgId ?? null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* End-to-end: a local-org member sees 'org'-scoped resources; a non-member
|
||||
* does not. Validates the whole chain — local org membership → resolveOrgIds
|
||||
* → session.orgIds → the provider-agnostic buildVisibilityWhere.
|
||||
*
|
||||
* See docs/superpowers/plans/2026-06-09-local-orgs.md.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository } from '../db/repository.js';
|
||||
import { runMigrations } from '../db/migrate.js';
|
||||
import { resolveOrgIds } from './auth.js';
|
||||
|
||||
function viewer(id: string, orgIds: string[]): Express.User {
|
||||
return {
|
||||
id, email: `${id}@x.com`, name: id, avatarUrl: null,
|
||||
role: 'user', status: 'active', orgIds,
|
||||
defaultVisibility: 'private', defaultVisibilityOrgId: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('local orgs — org visibility E2E', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-orgvis-'));
|
||||
repo = new Repository(join(tempDir, 'orchestrator.db'));
|
||||
runMigrations(repo.getDb());
|
||||
});
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
|
||||
});
|
||||
|
||||
it('resolveOrgIds includes the local orgs a user belongs to', () => {
|
||||
const alice = repo.createUser({ email: '[email protected]', name: 'A', role: 'user', status: 'active' }).id;
|
||||
const org = repo.createLocalOrg('Team', alice);
|
||||
repo.addOrgMember(org.id, alice);
|
||||
expect(resolveOrgIds(repo, alice)).toContain(org.id);
|
||||
});
|
||||
|
||||
it('an org member sees an org-scoped task; a non-member does not', async () => {
|
||||
const carol = repo.createUser({ email: '[email protected]', name: 'Carol', role: 'user', status: 'active' }).id;
|
||||
const alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' }).id;
|
||||
const bob = repo.createUser({ email: '[email protected]', name: 'Bob', role: 'user', status: 'active' }).id;
|
||||
const org = repo.createLocalOrg('Team', carol);
|
||||
repo.addOrgMember(org.id, carol);
|
||||
repo.addOrgMember(org.id, alice); // alice is a member, bob is not
|
||||
|
||||
await repo.createLocalTask({
|
||||
title: 'org task', body: 'b', ownerId: carol,
|
||||
visibility: 'org', visibilityScopeOrgId: org.id,
|
||||
});
|
||||
|
||||
const aliceTasks = await repo.listLocalTasks({ viewer: viewer(alice, resolveOrgIds(repo, alice)) });
|
||||
const bobTasks = await repo.listLocalTasks({ viewer: viewer(bob, resolveOrgIds(repo, bob)) });
|
||||
|
||||
expect(aliceTasks.some(t => t.title === 'org task')).toBe(true); // member sees it
|
||||
expect(bobTasks.some(t => t.title === 'org task')).toBe(false); // non-member does not
|
||||
});
|
||||
|
||||
it('the org name resolves on display after the COALESCE extension', async () => {
|
||||
const carol = repo.createUser({ email: '[email protected]', name: 'C', role: 'user', status: 'active' }).id;
|
||||
const org = repo.createLocalOrg('Engineering', carol);
|
||||
repo.addOrgMember(org.id, carol);
|
||||
const t = await repo.createLocalTask({
|
||||
title: 'x', body: 'b', ownerId: carol, visibility: 'org', visibilityScopeOrgId: org.id,
|
||||
});
|
||||
const got = await repo.getLocalTask(t.id, { viewer: viewer(carol, resolveOrgIds(repo, carol)) });
|
||||
expect(got?.visibilityScopeOrgName).toBe('Engineering');
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ import { setSessionManager } from '../engine/tools/browser.js';
|
||||
import { setUserFolderToolDeps } from '../engine/tools/user-folder.js';
|
||||
import { setSkillToolDeps } from '../engine/tools/skills.js';
|
||||
import { setAppDocsDeps } from '../engine/tools/app-docs.js';
|
||||
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler } from './auth.js';
|
||||
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, resolveOrgIds } from './auth.js';
|
||||
import { canUserSeeTask } from './visibility.js';
|
||||
import { mountAdminApi } from './admin-api.js';
|
||||
import { createAdminGatewayApi } from './admin-gateway-api.js';
|
||||
@@ -698,7 +698,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
getUserAccess: (userId) => {
|
||||
const user = repo.getUserById(userId);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const orgIds = repo.listUserGiteaOrgs(userId).map((o) => o.orgId);
|
||||
const orgIds = resolveOrgIds(repo, userId);
|
||||
return { isAdmin, orgIds };
|
||||
},
|
||||
sshExec,
|
||||
@@ -757,7 +757,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
avatarUrl: null,
|
||||
role: (user.role === 'admin' ? 'admin' : 'user'),
|
||||
status: 'active',
|
||||
orgIds: repo.listUserGiteaOrgs(user.id).map((o) => o.orgId),
|
||||
orgIds: resolveOrgIds(repo, user.id),
|
||||
defaultVisibility: 'private',
|
||||
defaultVisibilityOrgId: null,
|
||||
};
|
||||
@@ -773,7 +773,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
resolveSshAccess: async (user, session, task) => {
|
||||
const connection = connectionRepo.resolveConnection(session.connectionId);
|
||||
if (!connection) return false;
|
||||
const orgIds = repo.listUserGiteaOrgs(user.id).map((o) => o.orgId);
|
||||
const orgIds = resolveOrgIds(repo, user.id);
|
||||
const decision = accessResolver.resolveAccess({
|
||||
connection,
|
||||
userId: user.id,
|
||||
|
||||
@@ -15,7 +15,10 @@ const passthrough: RequestHandler = (_req, _res, next) => next();
|
||||
export function mountUsersApi(app: Application, repo: Repository, authActive = true): void {
|
||||
const guard = authActive ? requireAuth : passthrough;
|
||||
|
||||
// Viewer's cached Gitea orgs (populated at OAuth callback by the gitea strategy).
|
||||
// Viewer's orgs for the visibility ('org' scope) picker: Gitea orgs (cached
|
||||
// at OAuth callback) + local orgs (membership). Returning both here means
|
||||
// every picker (CreateTaskDialog / Schedules / DetailPanel / Preferences)
|
||||
// lists local orgs without any client change.
|
||||
app.get('/api/users/me/orgs', guard, (req: Request, res: Response) => {
|
||||
const user = req.user as Express.User | undefined;
|
||||
if (!user) {
|
||||
@@ -23,8 +26,9 @@ export function mountUsersApi(app: Application, repo: Repository, authActive = t
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
const orgs = repo.listUserGiteaOrgs(user.id);
|
||||
res.json({ orgs });
|
||||
const gitea = repo.listUserGiteaOrgs(user.id);
|
||||
const local = repo.listUserLocalOrgs(user.id).map(o => ({ orgId: o.orgId, orgName: o.name, fetchedAt: '' }));
|
||||
res.json({ orgs: [...gitea, ...local] });
|
||||
});
|
||||
|
||||
// Update viewer's per-user preferences (currently just default visibility).
|
||||
|
||||
@@ -555,5 +555,22 @@ function migratePushNotificationsTables(db: Database.Database): void {
|
||||
salt TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Local organizations + membership (provider-agnostic 'org' visibility for
|
||||
-- local accounts). Idempotent add.
|
||||
CREATE TABLE IF NOT EXISTS local_orgs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS local_org_members (
|
||||
org_id TEXT NOT NULL REFERENCES local_orgs(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (org_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_local_org_members_user ON local_org_members(user_id);
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Local organizations: org CRUD + membership + the session-orgIds query that
|
||||
* makes the existing provider-agnostic 'org' visibility work for local users.
|
||||
*
|
||||
* See docs/superpowers/plans/2026-06-09-local-orgs.md.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository } from './repository.js';
|
||||
import { runMigrations } from './migrate.js';
|
||||
|
||||
describe('Repository local orgs', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
let alice: string;
|
||||
let bob: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-localorgs-'));
|
||||
repo = new Repository(join(tempDir, 'orchestrator.db'));
|
||||
runMigrations(repo.getDb());
|
||||
alice = repo.createUser({ email: '[email protected]', name: 'Alice', role: 'user', status: 'active' }).id;
|
||||
bob = repo.createUser({ email: '[email protected]', name: 'Bob', role: 'user', status: 'active' }).id;
|
||||
});
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
|
||||
});
|
||||
|
||||
it('createLocalOrg assigns an lorg: id and is listable', () => {
|
||||
const org = repo.createLocalOrg('Team A', alice);
|
||||
expect(org.id).toMatch(/^lorg:/);
|
||||
expect(org.name).toBe('Team A');
|
||||
expect(org.createdBy).toBe(alice);
|
||||
expect(repo.listLocalOrgs().map(o => o.id)).toContain(org.id);
|
||||
expect(repo.getLocalOrg(org.id)?.name).toBe('Team A');
|
||||
});
|
||||
|
||||
it('lorg ids do not collide with Gitea numeric org ids', () => {
|
||||
const org = repo.createLocalOrg('Team', alice);
|
||||
// A Gitea org id like '42' must never equal a local org id.
|
||||
expect(org.id).not.toBe('42');
|
||||
expect(org.id.startsWith('lorg:')).toBe(true);
|
||||
});
|
||||
|
||||
it('renameLocalOrg updates the name', () => {
|
||||
const org = repo.createLocalOrg('Old', alice);
|
||||
repo.renameLocalOrg(org.id, 'New');
|
||||
expect(repo.getLocalOrg(org.id)?.name).toBe('New');
|
||||
});
|
||||
|
||||
it('membership add/remove + listOrgMembers', () => {
|
||||
const org = repo.createLocalOrg('Team', alice);
|
||||
repo.addOrgMember(org.id, alice);
|
||||
repo.addOrgMember(org.id, bob, 'owner');
|
||||
const members = repo.listOrgMembers(org.id);
|
||||
expect(members.map(m => m.userId).sort()).toEqual([alice, bob].sort());
|
||||
expect(members.find(m => m.userId === bob)?.role).toBe('owner');
|
||||
repo.removeOrgMember(org.id, bob);
|
||||
expect(repo.listOrgMembers(org.id).map(m => m.userId)).toEqual([alice]);
|
||||
});
|
||||
|
||||
it('addOrgMember is idempotent (re-add does not duplicate / can update role)', () => {
|
||||
const org = repo.createLocalOrg('Team', alice);
|
||||
repo.addOrgMember(org.id, alice, 'member');
|
||||
repo.addOrgMember(org.id, alice, 'owner');
|
||||
const members = repo.listOrgMembers(org.id);
|
||||
expect(members).toHaveLength(1);
|
||||
expect(members[0].role).toBe('owner');
|
||||
});
|
||||
|
||||
it('listUserLocalOrgs returns the orgs a user belongs to (feeds session.orgIds)', () => {
|
||||
const a = repo.createLocalOrg('A', alice);
|
||||
const b = repo.createLocalOrg('B', alice);
|
||||
repo.createLocalOrg('C', alice); // alice not a member
|
||||
repo.addOrgMember(a.id, alice);
|
||||
repo.addOrgMember(b.id, alice);
|
||||
const orgs = repo.listUserLocalOrgs(alice);
|
||||
expect(orgs.map(o => o.orgId).sort()).toEqual([a.id, b.id].sort());
|
||||
expect(orgs.find(o => o.orgId === a.id)?.name).toBe('A');
|
||||
expect(repo.listUserLocalOrgs(bob)).toEqual([]);
|
||||
});
|
||||
|
||||
it('deleteLocalOrg cascades members', () => {
|
||||
const org = repo.createLocalOrg('Team', alice);
|
||||
repo.addOrgMember(org.id, alice);
|
||||
repo.deleteLocalOrg(org.id);
|
||||
expect(repo.getLocalOrg(org.id)).toBeNull();
|
||||
expect(repo.listOrgMembers(org.id)).toEqual([]);
|
||||
expect(repo.listUserLocalOrgs(alice)).toEqual([]);
|
||||
});
|
||||
|
||||
it('deleteLocalOrg downgrades org-scoped resources to private (no orphan)', () => {
|
||||
const org = repo.createLocalOrg('Team', alice);
|
||||
const db = repo.getDb();
|
||||
// A local_task scoped to this org (visibility='org').
|
||||
db.prepare(
|
||||
`INSERT INTO local_tasks (title, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, owner_id, visibility, visibility_scope_org_id)
|
||||
VALUES ('t', 'b', 'general', 'auto', 'markdown', 'always', 'normal', '/tmp/x', @owner, 'org', @org)`,
|
||||
).run({ owner: alice, org: org.id });
|
||||
const before = db.prepare("SELECT visibility, visibility_scope_org_id FROM local_tasks WHERE owner_id=?").get(alice) as { visibility: string; visibility_scope_org_id: string | null };
|
||||
expect(before.visibility).toBe('org');
|
||||
|
||||
repo.deleteLocalOrg(org.id);
|
||||
|
||||
const after = db.prepare("SELECT visibility, visibility_scope_org_id FROM local_tasks WHERE owner_id=?").get(alice) as { visibility: string; visibility_scope_org_id: string | null };
|
||||
expect(after.visibility).toBe('private'); // downgraded, still owner-visible
|
||||
expect(after.visibility_scope_org_id).toBeNull();
|
||||
});
|
||||
|
||||
it('deleting a user cascades their memberships but leaves the org (created_by SET NULL)', () => {
|
||||
const org = repo.createLocalOrg('Team', alice);
|
||||
repo.addOrgMember(org.id, alice);
|
||||
repo.addOrgMember(org.id, bob);
|
||||
repo.deleteUser(bob);
|
||||
expect(repo.listOrgMembers(org.id).map(m => m.userId)).toEqual([alice]);
|
||||
// org still exists even if its creator is deleted
|
||||
repo.deleteUser(alice);
|
||||
expect(repo.getLocalOrg(org.id)).not.toBeNull();
|
||||
expect(repo.getLocalOrg(org.id)?.createdBy).toBeNull();
|
||||
});
|
||||
});
|
||||
+110
-2
@@ -30,13 +30,19 @@ const __dirname = dirname(__filename);
|
||||
*/
|
||||
const LOCAL_TASK_DISPLAY_SELECT = `
|
||||
u.name AS owner_name,
|
||||
(SELECT MIN(org_name) FROM user_gitea_orgs WHERE org_id = lt.visibility_scope_org_id) AS visibility_scope_org_name
|
||||
COALESCE(
|
||||
(SELECT MIN(org_name) FROM user_gitea_orgs WHERE org_id = lt.visibility_scope_org_id),
|
||||
(SELECT name FROM local_orgs WHERE id = lt.visibility_scope_org_id)
|
||||
) AS visibility_scope_org_name
|
||||
`.trim();
|
||||
const LOCAL_TASK_DISPLAY_JOIN = `LEFT JOIN users u ON u.id = lt.owner_id`;
|
||||
|
||||
const SCHEDULED_TASK_DISPLAY_SELECT = `
|
||||
u.name AS owner_name,
|
||||
(SELECT MIN(org_name) FROM user_gitea_orgs WHERE org_id = st.visibility_scope_org_id) AS visibility_scope_org_name
|
||||
COALESCE(
|
||||
(SELECT MIN(org_name) FROM user_gitea_orgs WHERE org_id = st.visibility_scope_org_id),
|
||||
(SELECT name FROM local_orgs WHERE id = st.visibility_scope_org_id)
|
||||
) AS visibility_scope_org_name
|
||||
`.trim();
|
||||
const SCHEDULED_TASK_DISPLAY_JOIN = `LEFT JOIN users u ON u.id = st.owner_id`;
|
||||
|
||||
@@ -580,6 +586,18 @@ export interface CreateLocalUserParams {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface LocalOrg {
|
||||
id: string;
|
||||
name: string;
|
||||
createdBy: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LocalOrgMember {
|
||||
userId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface GiteaOrgInput {
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
@@ -2578,6 +2596,96 @@ export class Repository {
|
||||
return user;
|
||||
}
|
||||
|
||||
// ── Local organizations ───────────────────────────────────────────────
|
||||
|
||||
private rowToLocalOrg(r: { id: string; name: string; created_by: string | null; created_at: string }): LocalOrg {
|
||||
return { id: r.id, name: r.name, createdBy: r.created_by, createdAt: r.created_at };
|
||||
}
|
||||
|
||||
/** Create a local org. id is prefixed `lorg:` so it never collides with a
|
||||
* Gitea numeric org id (both live in visibility_scope_org_id). */
|
||||
createLocalOrg(name: string, createdBy: string | null): LocalOrg {
|
||||
const id = `lorg:${uuidv4()}`;
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(`INSERT INTO local_orgs (id, name, created_by, created_at) VALUES (@id, @name, @createdBy, @now)`)
|
||||
.run({ id, name, createdBy, now });
|
||||
return { id, name, createdBy, createdAt: now };
|
||||
}
|
||||
|
||||
getLocalOrg(id: string): LocalOrg | null {
|
||||
const r = this.db.prepare('SELECT id, name, created_by, created_at FROM local_orgs WHERE id = ?').get(id) as
|
||||
| { id: string; name: string; created_by: string | null; created_at: string }
|
||||
| undefined;
|
||||
return r ? this.rowToLocalOrg(r) : null;
|
||||
}
|
||||
|
||||
listLocalOrgs(): LocalOrg[] {
|
||||
const rows = this.db.prepare('SELECT id, name, created_by, created_at FROM local_orgs ORDER BY name COLLATE NOCASE').all() as Array<{ id: string; name: string; created_by: string | null; created_at: string }>;
|
||||
return rows.map(r => this.rowToLocalOrg(r));
|
||||
}
|
||||
|
||||
renameLocalOrg(id: string, name: string): void {
|
||||
this.db.prepare('UPDATE local_orgs SET name = ? WHERE id = ?').run(name, id);
|
||||
}
|
||||
|
||||
/** Tables carrying `visibility_scope_org_id` (org-scoped resources). */
|
||||
private static readonly ORG_SCOPED_TABLES = ['local_tasks', 'scheduled_tasks', 'jobs', 'note_index'];
|
||||
|
||||
/**
|
||||
* Delete a local org. Members cascade via FK. Resources scoped to this org
|
||||
* (visibility='org', visibility_scope_org_id=id) would become invisible to
|
||||
* everyone once the org is gone — so first downgrade them to 'private'
|
||||
* (owner + admin can still see them; no data loss). Atomic.
|
||||
*/
|
||||
deleteLocalOrg(id: string): void {
|
||||
const tx = this.db.transaction((orgId: string) => {
|
||||
for (const table of Repository.ORG_SCOPED_TABLES) {
|
||||
this.db
|
||||
.prepare(`UPDATE ${table} SET visibility = 'private', visibility_scope_org_id = NULL WHERE visibility_scope_org_id = ?`)
|
||||
.run(orgId);
|
||||
}
|
||||
this.db.prepare('DELETE FROM local_orgs WHERE id = ?').run(orgId);
|
||||
});
|
||||
tx(id);
|
||||
}
|
||||
|
||||
/** Add or update a member (idempotent — re-add updates the role). */
|
||||
addOrgMember(orgId: string, userId: string, role: string = 'member'): void {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO local_org_members (org_id, user_id, role, added_at)
|
||||
VALUES (@orgId, @userId, @role, @now)
|
||||
ON CONFLICT(org_id, user_id) DO UPDATE SET role=@role`,
|
||||
)
|
||||
.run({ orgId, userId, role, now });
|
||||
}
|
||||
|
||||
removeOrgMember(orgId: string, userId: string): void {
|
||||
this.db.prepare('DELETE FROM local_org_members WHERE org_id = ? AND user_id = ?').run(orgId, userId);
|
||||
}
|
||||
|
||||
listOrgMembers(orgId: string): LocalOrgMember[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT user_id, role FROM local_org_members WHERE org_id = ? ORDER BY added_at')
|
||||
.all(orgId) as Array<{ user_id: string; role: string }>;
|
||||
return rows.map(r => ({ userId: r.user_id, role: r.role }));
|
||||
}
|
||||
|
||||
/** Orgs a user belongs to — merged into session.orgIds so the existing
|
||||
* provider-agnostic 'org' visibility (buildVisibilityWhere) covers them. */
|
||||
listUserLocalOrgs(userId: string): Array<{ orgId: string; name: string }> {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT o.id AS org_id, o.name AS name
|
||||
FROM local_org_members m JOIN local_orgs o ON o.id = m.org_id
|
||||
WHERE m.user_id = ? ORDER BY o.name COLLATE NOCASE`,
|
||||
)
|
||||
.all(userId) as Array<{ org_id: string; name: string }>;
|
||||
return rows.map(r => ({ orgId: r.org_id, name: r.name }));
|
||||
}
|
||||
|
||||
getUserById(id: string): User | null {
|
||||
const row = this.db
|
||||
.prepare('SELECT * FROM users WHERE id = ?')
|
||||
|
||||
@@ -173,6 +173,26 @@ CREATE TABLE IF NOT EXISTS local_credentials (
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Auth: local organizations. A provider-agnostic 'org' visibility scope for
|
||||
-- local accounts (Gitea orgs come from the Gitea API instead). org ids are
|
||||
-- prefixed 'lorg:' so they never collide with Gitea numeric org ids; a member's
|
||||
-- orgIds are injected into the session so buildVisibilityWhere works unchanged.
|
||||
-- See docs/superpowers/plans/2026-06-09-local-orgs.md.
|
||||
CREATE TABLE IF NOT EXISTS local_orgs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS local_org_members (
|
||||
org_id TEXT NOT NULL REFERENCES local_orgs(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (org_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_local_org_members_user ON local_org_members(user_id);
|
||||
|
||||
-- Auth: sessions (express-session)
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
|
||||
Reference in New Issue
Block a user