sync: update from private repo (caeb95c)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
2ec9853655
commit
454d6f957b
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
75
src/bridge/local-orgs.integration.test.ts
Normal file
75
src/bridge/local-orgs.integration.test.ts
Normal file
@ -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: 'a@x.com', 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: 'carol@x.com', name: 'Carol', role: 'user', status: 'active' }).id;
|
||||
const alice = repo.createUser({ email: 'alice@x.com', name: 'Alice', role: 'user', status: 'active' }).id;
|
||||
const bob = repo.createUser({ email: 'bob@x.com', 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: 'c@x.com', 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);
|
||||
`);
|
||||
}
|
||||
|
||||
124
src/db/repository-local-orgs.test.ts
Normal file
124
src/db/repository-local-orgs.test.ts
Normal file
@ -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: 'alice@x.com', name: 'Alice', role: 'user', status: 'active' }).id;
|
||||
bob = repo.createUser({ email: 'bob@x.com', 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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -29,6 +29,7 @@ import { GatewayServerForm } from './GatewayServerForm';
|
||||
import { NotesForm } from './NotesForm';
|
||||
import { PushNotificationsForm } from './PushNotificationsForm';
|
||||
import { AuthForm } from './AuthForm';
|
||||
import { OrgsForm } from './OrgsForm';
|
||||
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
@ -94,6 +95,11 @@ export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
|
||||
if (!isAdmin) {
|
||||
return <div className="max-w-2xl text-sm text-slate-500">この設定は管理者のみ閲覧できます。</div>;
|
||||
}
|
||||
// Local organizations: admin-managed via /api/admin/orgs (not config.yaml),
|
||||
// so render stand-alone without the global save bar.
|
||||
if (section === 'organizations') {
|
||||
return <OrgsForm />;
|
||||
}
|
||||
// Step 8: 'gateway-keys' bookmarks are redirected to 'gateway-server'
|
||||
// by SettingsPage via LEGACY_SECTION_REDIRECT, so we no longer need a
|
||||
// dedicated branch here. The keys UI lives inside GatewayServerForm
|
||||
|
||||
185
ui/src/components/settings/OrgsForm.tsx
Normal file
185
ui/src/components/settings/OrgsForm.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
/**
|
||||
* Settings → System → Organizations (admin).
|
||||
*
|
||||
* Local organizations give local accounts a provider-agnostic 'org' visibility
|
||||
* scope (Gitea orgs come from Gitea instead). Uses /api/admin/orgs directly —
|
||||
* no config.yaml, no Save bar. See docs/superpowers/plans/2026-06-09-local-orgs.md.
|
||||
*/
|
||||
interface OrgMember { userId: string; role: string }
|
||||
interface LocalOrg { id: string; name: string; createdBy: string | null; createdAt: string; members: OrgMember[] }
|
||||
interface UserLite { id: string; email: string; name: string | null }
|
||||
|
||||
async function jget<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function OrgsForm() {
|
||||
const qc = useQueryClient();
|
||||
const orgsQ = useQuery<LocalOrg[]>({
|
||||
queryKey: ['admin', 'orgs'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/admin/orgs');
|
||||
if (res.status === 401 || res.status === 403) return [];
|
||||
if (!res.ok) throw new Error('failed');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
const usersQ = useQuery<UserLite[]>({
|
||||
queryKey: ['admin', 'users'],
|
||||
queryFn: () => jget<UserLite[]>('/api/admin/users'),
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['admin', 'orgs'] });
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const res = await fetch('/api/admin/orgs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
|
||||
if (!res.ok) throw new Error('create failed');
|
||||
},
|
||||
onSuccess: () => { setNewName(''); invalidate(); },
|
||||
});
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (id: string) => { await fetch(`/api/admin/orgs/${encodeURIComponent(id)}`, { method: 'DELETE' }); },
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const renameMut = useMutation({
|
||||
mutationFn: async ({ id, name }: { id: string; name: string }) => {
|
||||
await fetch(`/api/admin/orgs/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const addMemberMut = useMutation({
|
||||
mutationFn: async ({ id, userId }: { id: string; userId: string }) => {
|
||||
await fetch(`/api/admin/orgs/${encodeURIComponent(id)}/members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) });
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const removeMemberMut = useMutation({
|
||||
mutationFn: async ({ id, userId }: { id: string; userId: string }) => {
|
||||
await fetch(`/api/admin/orgs/${encodeURIComponent(id)}/members/${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const orgs = orgsQ.data ?? [];
|
||||
const users = usersQ.data ?? [];
|
||||
const userLabel = (id: string) => {
|
||||
const u = users.find(x => x.id === id);
|
||||
return u ? (u.name || u.email) : id;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-1">Organizations</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
ローカルアカウント向けの組織。タスク/スケジュールの可視性を <code>org</code> にして、
|
||||
同じ組織のメンバーと共有できます(Gitea 組織とは別系統)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={e => { e.preventDefault(); if (newName.trim()) createMut.mutate(newName.trim()); }}
|
||||
>
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="新しい組織名"
|
||||
className="flex-1 h-9 px-2.5 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-canvas"
|
||||
/>
|
||||
<button type="submit" disabled={!newName.trim() || createMut.isPending} className="px-3 h-9 rounded-md text-xs font-semibold bg-accent text-white disabled:opacity-50 hover:opacity-90 whitespace-nowrap">
|
||||
+ 組織を作成
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{orgsQ.isLoading && <div className="text-xs text-slate-500">読み込み中...</div>}
|
||||
{!orgsQ.isLoading && orgs.length === 0 && (
|
||||
<div className="text-xs text-slate-400 border border-dashed border-slate-200 rounded p-4 text-center">
|
||||
組織がありません。上で作成してください。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{orgs.map(org => (
|
||||
<OrgCard
|
||||
key={org.id}
|
||||
org={org}
|
||||
users={users}
|
||||
userLabel={userLabel}
|
||||
onRename={(name) => renameMut.mutate({ id: org.id, name })}
|
||||
onDelete={() => { if (confirm(`組織「${org.name}」を削除しますか?\nこの組織に共有されているタスク等は private に戻ります。`)) deleteMut.mutate(org.id); }}
|
||||
onAddMember={(userId) => addMemberMut.mutate({ id: org.id, userId })}
|
||||
onRemoveMember={(userId) => removeMemberMut.mutate({ id: org.id, userId })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<HelpText>メンバーを追加/削除すると、そのユーザーのセッションは無効化され、次回アクセス時に共有が反映されます。</HelpText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgCard({ org, users, userLabel, onRename, onDelete, onAddMember, onRemoveMember }: {
|
||||
org: LocalOrg;
|
||||
users: UserLite[];
|
||||
userLabel: (id: string) => string;
|
||||
onRename: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
onAddMember: (userId: string) => void;
|
||||
onRemoveMember: (userId: string) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(org.name);
|
||||
const memberIds = new Set(org.members.map(m => m.userId));
|
||||
const addable = users.filter(u => !memberIds.has(u.id));
|
||||
const [pick, setPick] = useState('');
|
||||
|
||||
return (
|
||||
<div className="border border-hairline rounded-lg p-4 bg-canvas">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onBlur={() => { if (name.trim() && name.trim() !== org.name) onRename(name.trim()); }}
|
||||
className="flex-1 h-8 px-2 text-[13px] font-medium border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-canvas"
|
||||
/>
|
||||
<button type="button" onClick={onDelete} className="px-2.5 h-8 rounded-md text-xs font-medium border border-red-200 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/15 whitespace-nowrap">
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-2xs text-slate-500 mb-1.5">メンバー({org.members.length})</div>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2.5">
|
||||
{org.members.length === 0 && <span className="text-2xs text-slate-400">メンバーがいません</span>}
|
||||
{org.members.map(m => (
|
||||
<span key={m.userId} className="inline-flex items-center gap-1.5 pl-2 pr-1 h-6 rounded border border-hairline bg-surface text-slate-700 text-2xs">
|
||||
{userLabel(m.userId)}
|
||||
{m.role === 'owner' && <span className="text-[9px] text-blue-600">owner</span>}
|
||||
<button type="button" onClick={() => onRemoveMember(m.userId)} title="削除" className="text-slate-400 hover:text-red-500 leading-none px-0.5">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={pick} onChange={e => setPick(e.target.value)} className="flex-1 h-8 px-2 text-xs border border-hairline rounded-md bg-canvas">
|
||||
<option value="">メンバーを追加...</option>
|
||||
{addable.map(u => <option key={u.id} value={u.id}>{u.name || u.email}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!pick}
|
||||
onClick={() => { if (pick) { onAddMember(pick); setPick(''); } }}
|
||||
className="px-3 h-8 rounded-md text-xs font-medium border border-accent/60 text-accent hover:bg-accent-soft disabled:opacity-40 whitespace-nowrap"
|
||||
>
|
||||
追加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -35,6 +35,7 @@ const CONFIG_GROUPS = [
|
||||
{ id: 'paths-storage', label: 'Paths & Storage' },
|
||||
{ id: 'execution', label: 'Execution' },
|
||||
{ id: 'auth', label: 'Authentication' },
|
||||
{ id: 'organizations', label: '🏢 Organizations' },
|
||||
{ id: 'push-notifications', label: 'Web Push (Server)' },
|
||||
],
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user