From 454d6f957b46b3de96994848f66d98349fe81c5e Mon Sep 17 00:00:00 2001 From: oss-sync Date: Tue, 9 Jun 2026 12:59:57 +0000 Subject: [PATCH] sync: update from private repo (caeb95c) --- src/bridge/admin-api.ts | 65 ++++++ src/bridge/auth.ts | 18 +- src/bridge/local-orgs.integration.test.ts | 75 +++++++ src/bridge/server.ts | 8 +- src/bridge/users-api.ts | 10 +- src/db/migrate.ts | 17 ++ src/db/repository-local-orgs.test.ts | 124 ++++++++++++ src/db/repository.ts | 112 ++++++++++- src/db/schema.sql | 20 ++ ui/src/components/settings/ConfigForm.tsx | 6 + ui/src/components/settings/OrgsForm.tsx | 185 ++++++++++++++++++ .../components/settings/SettingsSidebar.tsx | 1 + 12 files changed, 628 insertions(+), 13 deletions(-) create mode 100644 src/bridge/local-orgs.integration.test.ts create mode 100644 src/db/repository-local-orgs.test.ts create mode 100644 ui/src/components/settings/OrgsForm.tsx diff --git a/src/bridge/admin-api.ts b/src/bridge/admin-api.ts index 3ac27fc..db454cc 100644 --- a/src/bridge/admin-api.ts +++ b/src/bridge/admin-api.ts @@ -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(); + }); } diff --git a/src/bridge/auth.ts b/src/bridge/auth.ts index b3fc932..8bf47ed 100644 --- a/src/bridge/auth.ts +++ b/src/bridge/auth.ts @@ -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, }; diff --git a/src/bridge/local-orgs.integration.test.ts b/src/bridge/local-orgs.integration.test.ts new file mode 100644 index 0000000..938c7c9 --- /dev/null +++ b/src/bridge/local-orgs.integration.test.ts @@ -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'); + }); +}); diff --git a/src/bridge/server.ts b/src/bridge/server.ts index a0258ba..beda7bf 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -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, diff --git a/src/bridge/users-api.ts b/src/bridge/users-api.ts index 77793a9..f6bb3a8 100644 --- a/src/bridge/users-api.ts +++ b/src/bridge/users-api.ts @@ -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). diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 209375b..b12138e 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -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); `); } diff --git a/src/db/repository-local-orgs.test.ts b/src/db/repository-local-orgs.test.ts new file mode 100644 index 0000000..2f27549 --- /dev/null +++ b/src/db/repository-local-orgs.test.ts @@ -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(); + }); +}); diff --git a/src/db/repository.ts b/src/db/repository.ts index ef2264d..4bfe2db 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -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 = ?') diff --git a/src/db/schema.sql b/src/db/schema.sql index 7de7df7..eec3b27 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -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, diff --git a/ui/src/components/settings/ConfigForm.tsx b/ui/src/components/settings/ConfigForm.tsx index 506c604..fe655d1 100644 --- a/ui/src/components/settings/ConfigForm.tsx +++ b/ui/src/components/settings/ConfigForm.tsx @@ -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
この設定は管理者のみ閲覧できます。
; } + // Local organizations: admin-managed via /api/admin/orgs (not config.yaml), + // so render stand-alone without the global save bar. + if (section === 'organizations') { + return ; + } // 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 diff --git a/ui/src/components/settings/OrgsForm.tsx b/ui/src/components/settings/OrgsForm.tsx new file mode 100644 index 0000000..dea479f --- /dev/null +++ b/ui/src/components/settings/OrgsForm.tsx @@ -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(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`${res.status}`); + return res.json() as Promise; +} + +export function OrgsForm() { + const qc = useQueryClient(); + const orgsQ = useQuery({ + 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({ + queryKey: ['admin', 'users'], + queryFn: () => jget('/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 ( +
+
+

Organizations

+

+ ローカルアカウント向けの組織。タスク/スケジュールの可視性を org にして、 + 同じ組織のメンバーと共有できます(Gitea 組織とは別系統)。 +

+
+ +
{ e.preventDefault(); if (newName.trim()) createMut.mutate(newName.trim()); }} + > + 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" + /> + +
+ + {orgsQ.isLoading &&
読み込み中...
} + {!orgsQ.isLoading && orgs.length === 0 && ( +
+ 組織がありません。上で作成してください。 +
+ )} + +
+ {orgs.map(org => ( + 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 })} + /> + ))} +
+ メンバーを追加/削除すると、そのユーザーのセッションは無効化され、次回アクセス時に共有が反映されます。 +
+ ); +} + +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 ( +
+
+ 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" + /> + +
+ +
メンバー({org.members.length})
+
+ {org.members.length === 0 && メンバーがいません} + {org.members.map(m => ( + + {userLabel(m.userId)} + {m.role === 'owner' && owner} + + + ))} +
+ +
+ + +
+
+ ); +} diff --git a/ui/src/components/settings/SettingsSidebar.tsx b/ui/src/components/settings/SettingsSidebar.tsx index 610d4aa..47ffe2f 100644 --- a/ui/src/components/settings/SettingsSidebar.tsx +++ b/ui/src/components/settings/SettingsSidebar.tsx @@ -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)' }, ], },