sync: update from private repo (caeb95c)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-09 12:59:57 +00:00
parent 2ec9853655
commit 454d6f957b
12 changed files with 628 additions and 13 deletions

View File

@ -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();
});
}

View File

@ -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,
};

View 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');
});
});

View File

@ -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,

View File

@ -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).

View File

@ -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);
`);
}

View 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();
});
});

View File

@ -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 = ?')

View File

@ -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,

View File

@ -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

View 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>
);
}

View File

@ -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)' },
],
},