diff --git a/config.yaml.example b/config.yaml.example
index c247004..fc6b41a 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -315,7 +315,7 @@ tools:
# secure_cookie: false # HTTPS 環境では true
# admin_emails:
# - "admin@example.com"
-# # primary_provider: gitea # 'google' | 'gitea'。両方有効時に明示
+# # primary_provider: gitea # 'google' | 'gitea' | 'local'。複数有効時に明示
# providers:
# google:
# client_id: ""
@@ -326,6 +326,15 @@ tools:
# client_secret: ""
# base_url: "https://gitea.example.com"
# callback_url: "http://localhost:3000/auth/gitea/callback"
+# # ローカルアカウント (email + password)。OAuth を立てずにパスワード保護したい時。
+# # 既存の no-auth デプロイに後付け可: 起動時に bootstrap_admin を id='local' で冪等 seed し、
+# # no-auth 時代に 'local' 所有で溜まったデータをそのまま admin が引き継ぐ。
+# local:
+# enabled: true
+# allow_signup: false # true でセルフ登録 (status=pending → admin 承認)
+# bootstrap_admin: # 初回 admin (id='local')。seed 後は Settings/CLI で変更
+# email: "admin@example.com"
+# password: "強いパスワードを設定" # 平文保存される。初回ログイン後に変更推奨
# ─── Branding (オプション) ────────────────────────────────────
# config.yaml / data/branding/ は .gitignore 済みで git pull 影響なし。
diff --git a/src/bridge/admin-api.local.test.ts b/src/bridge/admin-api.local.test.ts
new file mode 100644
index 0000000..d3116de
--- /dev/null
+++ b/src/bridge/admin-api.local.test.ts
@@ -0,0 +1,145 @@
+/**
+ * Admin user-management with local accounts: create, password reset, and the
+ * deletion path (folder removal + local-user guard).
+ *
+ * Uses a real admin session (request.agent cookie jar) so requireAdmin passes.
+ * See docs/superpowers/plans/2026-06-09-local-auth.md.
+ */
+import { afterAll, beforeAll, describe, it, expect } from 'vitest';
+import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'fs';
+import { join } from 'path';
+import { tmpdir } from 'os';
+import express, { type Express } from 'express';
+import request from 'supertest';
+import { Repository } from '../db/repository.js';
+import { runMigrations } from '../db/migrate.js';
+import { setupAuth, requireAuth, buildChangePasswordHandler } from './auth.js';
+import { mountAdminApi } from './admin-api.js';
+import { ensureUserFolder } from '../user-folder/paths.js';
+import type { AuthConfig } from '../config.js';
+
+const AUTH: AuthConfig = {
+ sessionSecret: 'test', sessionMaxAge: 3600_000, secureCookie: false,
+ adminEmails: [], providers: {}, local: { enabled: true },
+};
+
+describe('admin-api local user management', () => {
+ let tempDir = '';
+ let userFolderRoot = '';
+ let repo: Repository;
+ let app: Express;
+
+ // setupAuth ONCE per file: passport.serializeUser/deserializeUser register
+ // onto a stack (they don't replace), so calling setupAuth per-test would
+ // leave earlier tests' deserializers (bound to closed repos) running first.
+ // The server only ever calls setupAuth once, so this mirrors production.
+ beforeAll(() => {
+ tempDir = mkdtempSync(join(tmpdir(), 'maestro-adminlocal-'));
+ userFolderRoot = join(tempDir, 'users');
+ repo = new Repository(join(tempDir, 'orchestrator.db'));
+ runMigrations(repo.getDb());
+ repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'adminpw12' });
+
+ app = express();
+ const auth = setupAuth(repo, AUTH);
+ app.use(auth.sessionMiddleware);
+ app.use(auth.passportInit);
+ app.use(auth.passportSession);
+ app.use('/auth', auth.authRouter);
+ app.use('/api/admin', express.json());
+ mountAdminApi(app, repo, true, userFolderRoot);
+ // Self-service password change (mounted the same way server.ts does).
+ app.post('/api/auth/password', requireAuth, express.json(), buildChangePasswordHandler(repo));
+ });
+ afterAll(() => {
+ repo.close();
+ if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
+ });
+
+ async function adminAgent() {
+ const agent = request.agent(app);
+ await agent.post('/auth/local').type('form').send({ email: 'admin@x.com', password: 'adminpw12' });
+ return agent;
+ }
+
+ it('POST creates a local user (active) that can authenticate', async () => {
+ const agent = await adminAgent();
+ const res = await agent.post('/api/admin/users').send({ email: 'bob@x.com', password: 'bobpw1234' });
+ expect(res.status).toBe(201);
+ const u = repo.getUserByEmail('bob@x.com')!;
+ expect(u.status).toBe('active');
+ expect(repo.verifyLocalPassword(u.id, 'bobpw1234')).toBe(true);
+ });
+
+ it('POST rejects a duplicate email (409)', async () => {
+ const agent = await adminAgent();
+ await agent.post('/api/admin/users').send({ email: 'dup@x.com', password: 'pw123456' });
+ const res = await agent.post('/api/admin/users').send({ email: 'dup@x.com', password: 'other123' });
+ expect(res.status).toBe(409);
+ });
+
+ it('POST .../password resets the password', async () => {
+ const agent = await adminAgent();
+ const u = repo.createLocalUser({ email: 'carol@x.com', password: 'oldpw1234', role: 'user', status: 'active' });
+ const res = await agent.post(`/api/admin/users/${u.id}/password`).send({ password: 'newpw1234' });
+ expect(res.status).toBe(204);
+ expect(repo.verifyLocalPassword(u.id, 'oldpw1234')).toBe(false);
+ expect(repo.verifyLocalPassword(u.id, 'newpw1234')).toBe(true);
+ });
+
+ it('DELETE removes the user AND their on-disk folder', async () => {
+ const agent = await adminAgent();
+ const u = repo.createLocalUser({ email: 'dave@x.com', password: 'davepw12', role: 'user', status: 'active' });
+ const dir = ensureUserFolder(userFolderRoot, u.id);
+ writeFileSync(join(dir, 'notes', 'secret.md'), 'data');
+ expect(existsSync(dir)).toBe(true);
+
+ const res = await agent.delete(`/api/admin/users/${u.id}`);
+ expect(res.status).toBe(204);
+ expect(repo.getUserById(u.id)).toBeNull();
+ expect(existsSync(dir)).toBe(false); // folder gone, no orphan
+ });
+
+ it('DELETE refuses the local/system user (400) and preserves it + its folder', async () => {
+ const agent = await adminAgent();
+ const dir = ensureUserFolder(userFolderRoot, 'local');
+ const res = await agent.delete('/api/admin/users/local');
+ expect(res.status).toBe(400);
+ expect(repo.getUserById('local')).not.toBeNull();
+ expect(existsSync(dir)).toBe(true);
+ });
+
+ it('mutations require an admin session (401/403 without login)', async () => {
+ const res = await request(app).delete('/api/admin/users/whoever');
+ expect([401, 403]).toContain(res.status);
+ });
+
+ // ── self-service password change ──────────────────────────────
+
+ it('POST /api/auth/password changes own password with the correct current one', async () => {
+ const agent = await adminAgent(); // logged in as the local admin (pw adminpw12)
+ const res = await agent.post('/api/auth/password').send({ currentPassword: 'adminpw12', newPassword: 'brandnewpw1' });
+ expect(res.status).toBe(204);
+ expect(repo.verifyLocalPassword('local', 'brandnewpw1')).toBe(true);
+ // restore so later assumptions about the admin password hold
+ repo.setLocalPassword('local', 'adminpw12');
+ });
+
+ it('POST /api/auth/password rejects a wrong current password (403)', async () => {
+ const agent = await adminAgent();
+ const res = await agent.post('/api/auth/password').send({ currentPassword: 'WRONG', newPassword: 'whatever123' });
+ expect(res.status).toBe(403);
+ expect(repo.verifyLocalPassword('local', 'adminpw12')).toBe(true); // unchanged
+ });
+
+ it('POST /api/auth/password rejects a too-short new password (400)', async () => {
+ const agent = await adminAgent();
+ const res = await agent.post('/api/auth/password').send({ currentPassword: 'adminpw12', newPassword: 'short' });
+ expect(res.status).toBe(400);
+ });
+
+ it('POST /api/auth/password requires authentication (401/403)', async () => {
+ const res = await request(app).post('/api/auth/password').send({ currentPassword: 'x', newPassword: 'yyyyyyyy' });
+ expect([401, 403]).toContain(res.status);
+ });
+});
diff --git a/src/bridge/admin-api.ts b/src/bridge/admin-api.ts
index b65692d..3ac27fc 100644
--- a/src/bridge/admin-api.ts
+++ b/src/bridge/admin-api.ts
@@ -1,10 +1,16 @@
import { type Application, type Request, type Response, type RequestHandler } from 'express';
import type { Repository } from '../db/repository.js';
import { requireAdmin } from './auth.js';
+import { deleteUserFolder } from '../user-folder/paths.js';
const passthrough: RequestHandler = (_req, _res, next) => next();
-export function mountAdminApi(app: Application, repo: Repository, authActive = true): void {
+export function mountAdminApi(
+ app: Application,
+ repo: Repository,
+ authActive = true,
+ userFolderRoot = './data/users',
+): void {
const guard = authActive ? requireAdmin : passthrough;
app.get('/api/admin/users', guard, (_req: Request, res: Response) => {
@@ -12,10 +18,45 @@ export function mountAdminApi(app: Application, repo: Repository, authActive = t
const enriched = users.map(u => ({
...u,
orgs: repo.listUserGiteaOrgs(u.id),
+ hasLocalPassword: repo.hasLocalCredential(u.id),
}));
res.json(enriched);
});
+ // Create a local (email + password) account. Admin-created accounts are
+ // pre-approved (status=active). Rejects an email that already exists.
+ app.post('/api/admin/users', guard, (req: Request, res: Response) => {
+ if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
+ const { email, password, role } = (req.body ?? {}) as { email?: string; password?: string; role?: string };
+ if (typeof email !== 'string' || !email.trim() || typeof password !== 'string' || password.length < 8) {
+ res.status(400).json({ error: 'email and a password of at least 8 characters are required' });
+ return;
+ }
+ const wantRole = role === 'admin' ? 'admin' : 'user';
+ try {
+ const user = repo.createLocalUser({ email: email.trim(), password, role: wantRole, status: 'active' });
+ res.status(201).json(user);
+ } catch {
+ res.status(409).json({ error: 'a user with that email already exists' });
+ }
+ });
+
+ // Reset (or set) a user's local password. Invalidates their sessions so the
+ // new password must be used.
+ app.post('/api/admin/users/:id/password', guard, (req: Request, res: Response) => {
+ if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
+ const { id } = req.params;
+ const { password } = (req.body ?? {}) as { password?: string };
+ if (typeof password !== 'string' || password.length < 8) {
+ res.status(400).json({ error: 'password must be at least 8 characters' });
+ return;
+ }
+ if (!repo.getUserById(id)) { res.status(404).json({ error: 'User not found' }); return; }
+ repo.setLocalPassword(id, password);
+ repo.deleteSessionsByUserId(id);
+ res.status(204).end();
+ });
+
app.patch('/api/admin/users/:id', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params;
@@ -39,6 +80,12 @@ export function mountAdminApi(app: Application, repo: Repository, authActive = t
app.delete('/api/admin/users/:id', guard, (req: Request, res: Response) => {
if (!authActive) { res.status(403).json({ error: 'Auth is not configured' }); return; }
const { id } = req.params;
+ // The shared `local` system/admin user is the no-auth fallback owner and
+ // owns all single-user-mode data — never deletable.
+ if (id === 'local') {
+ res.status(400).json({ error: 'the local/system user cannot be deleted' });
+ return;
+ }
const user = repo.getUserById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
@@ -46,6 +93,9 @@ export function mountAdminApi(app: Application, repo: Repository, authActive = t
}
repo.deleteSessionsByUserId(id);
repo.deleteUser(id);
+ // Remove the on-disk user folder too — the DB cascade only handles rows.
+ // Without this the user's scripts/macros/recordings/notes orphaned on disk.
+ deleteUserFolder(userFolderRoot, id);
res.status(204).end();
});
}
diff --git a/src/bridge/auth-login.html b/src/bridge/auth-login.html
index 634c2a0..4ed20f2 100644
--- a/src/bridge/auth-login.html
+++ b/src/bridge/auth-login.html
@@ -325,6 +325,33 @@
+
+
+
または
+
+
+
+ アカウントが無い場合
+
+
+
+
diff --git a/src/bridge/auth.local.test.ts b/src/bridge/auth.local.test.ts
new file mode 100644
index 0000000..dd9b050
--- /dev/null
+++ b/src/bridge/auth.local.test.ts
@@ -0,0 +1,137 @@
+/**
+ * Local-auth HTTP routes + helpers: login, self-signup, login-page rendering,
+ * and the primary=local provider gating.
+ *
+ * See docs/superpowers/plans/2026-06-09-local-auth.md.
+ */
+import { afterEach, beforeEach, describe, it, expect } from 'vitest';
+import { mkdtempSync, rmSync } from 'fs';
+import { join } from 'path';
+import { tmpdir } from 'os';
+import express, { type Express } from 'express';
+import request from 'supertest';
+import { Repository } from '../db/repository.js';
+import { runMigrations } from '../db/migrate.js';
+import { setupAuth, isLocalEnabled, isProviderActive } from './auth.js';
+import type { AuthConfig } from '../config.js';
+
+function mkAuthConfig(over: Partial = {}): AuthConfig {
+ return {
+ sessionSecret: 'test-secret',
+ sessionMaxAge: 3600_000,
+ secureCookie: false,
+ adminEmails: [],
+ providers: {},
+ local: { enabled: true, allowSignup: true },
+ ...over,
+ };
+}
+
+function mkApp(repo: Repository, authConfig: AuthConfig): Express {
+ const app = express();
+ const auth = setupAuth(repo, authConfig);
+ app.use(auth.sessionMiddleware);
+ app.use(auth.passportInit);
+ app.use(auth.passportSession);
+ app.use('/auth', auth.authRouter);
+ app.get('/', (_req, res) => res.send('home'));
+ return app;
+}
+
+describe('isLocalEnabled', () => {
+ it('reflects auth.local.enabled', () => {
+ expect(isLocalEnabled(mkAuthConfig({ local: { enabled: true } }))).toBe(true);
+ expect(isLocalEnabled(mkAuthConfig({ local: { enabled: false } }))).toBe(false);
+ expect(isLocalEnabled(mkAuthConfig({ local: undefined }))).toBe(false);
+ });
+});
+
+describe('isProviderActive with primary=local', () => {
+ it('turns OAuth providers OFF when primary is local', () => {
+ const c = mkAuthConfig({
+ primaryProvider: 'local',
+ providers: {
+ google: { clientId: 'g', clientSecret: 's', callbackUrl: 'http://x/cb' },
+ },
+ });
+ expect(isProviderActive(c, 'google')).toBe(false);
+ });
+});
+
+describe('local auth routes', () => {
+ let tempDir = '';
+ let repo: Repository;
+ let app: Express;
+
+ beforeEach(() => {
+ tempDir = mkdtempSync(join(tmpdir(), 'maestro-localroutes-'));
+ repo = new Repository(join(tempDir, 'orchestrator.db'));
+ runMigrations(repo.getDb());
+ app = mkApp(repo, mkAuthConfig());
+ });
+ afterEach(() => {
+ repo.close();
+ if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
+ });
+
+ it('GET /auth/login renders the local form when enabled', async () => {
+ const res = await request(app).get('/auth/login');
+ expect(res.status).toBe(200);
+ expect(res.text).toContain('action="/auth/local"');
+ expect(res.text).toContain('action="/auth/local/signup"'); // signup on
+ });
+
+ it('POST /auth/local with valid active creds redirects home', async () => {
+ repo.createLocalUser({ email: 'a@x.com', password: 'pw12345678', role: 'user', status: 'active' });
+ const res = await request(app).post('/auth/local').type('form').send({ email: 'a@x.com', password: 'pw12345678' });
+ expect(res.status).toBe(302);
+ expect(res.headers['location']).toBe('/');
+ });
+
+ it('POST /auth/local with wrong password redirects to login error', async () => {
+ repo.createLocalUser({ email: 'b@x.com', password: 'rightpassword', role: 'user', status: 'active' });
+ const res = await request(app).post('/auth/local').type('form').send({ email: 'b@x.com', password: 'nope' });
+ expect(res.status).toBe(302);
+ expect(res.headers['location']).toContain('/auth/login?error=');
+ });
+
+ it('POST /auth/local for a pending user redirects to the pending page', async () => {
+ repo.createLocalUser({ email: 'c@x.com', password: 'pw12345678', role: 'user', status: 'pending' });
+ const res = await request(app).post('/auth/local').type('form').send({ email: 'c@x.com', password: 'pw12345678' });
+ expect(res.status).toBe(302);
+ expect(res.headers['location']).toBe('/auth/pending');
+ });
+
+ it('POST /auth/local/signup creates a pending user', async () => {
+ const res = await request(app).post('/auth/local/signup').type('form').send({ email: 'new@x.com', password: 'pw12345678' });
+ expect(res.status).toBe(302);
+ expect(res.headers['location']).toBe('/auth/pending');
+ const u = repo.getUserByEmail('new@x.com');
+ expect(u?.status).toBe('pending');
+ expect(repo.verifyLocalPassword(u!.id, 'pw12345678')).toBe(true);
+ });
+
+ it('POST /auth/local/signup rejects a short password', async () => {
+ const res = await request(app).post('/auth/local/signup').type('form').send({ email: 'short@x.com', password: 'abc' });
+ expect(res.status).toBe(302);
+ expect(res.headers['location']).toContain('error=weak');
+ expect(repo.getUserByEmail('short@x.com')).toBeNull();
+ });
+
+ it('POST /auth/local/signup does not disclose an existing email (generic error, no overwrite)', async () => {
+ repo.createLocalUser({ email: 'dup@x.com', password: 'originalpw', role: 'user', status: 'active' });
+ const res = await request(app).post('/auth/local/signup').type('form').send({ email: 'dup@x.com', password: 'attackerpw' });
+ expect(res.status).toBe(302);
+ expect(res.headers['location']).toContain('error=signup');
+ // original password unchanged (no takeover)
+ const u = repo.getUserByEmail('dup@x.com')!;
+ expect(repo.verifyLocalPassword(u.id, 'originalpw')).toBe(true);
+ expect(repo.verifyLocalPassword(u.id, 'attackerpw')).toBe(false);
+ });
+
+ it('signup route is absent when allowSignup is off', async () => {
+ const app2 = mkApp(repo, mkAuthConfig({ local: { enabled: true, allowSignup: false } }));
+ const res = await request(app2).post('/auth/local/signup').type('form').send({ email: 'x@x.com', password: 'pw12345678' });
+ expect(res.status).toBe(404);
+ });
+});
diff --git a/src/bridge/auth.ts b/src/bridge/auth.ts
index 2688952..b3fc932 100644
--- a/src/bridge/auth.ts
+++ b/src/bridge/auth.ts
@@ -10,7 +10,7 @@ import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import type { Database } from 'better-sqlite3';
import type { AuthConfig, AuthProviderConfig } from '../config.js';
-import type { Repository } from '../db/repository.js';
+import type { Repository, User } from '../db/repository.js';
import { logger } from '../logger.js';
import { randomBytes } from 'crypto';
@@ -71,9 +71,48 @@ export function isProviderActive(authConfig: AuthConfig, kind: 'google' | 'gitea
const primary = authConfig.primaryProvider;
if (primary === 'google' && isProviderConfigured(authConfig.providers.google, 'google')) return kind === 'google';
if (primary === 'gitea' && isProviderConfigured(authConfig.providers.gitea, 'gitea')) return kind === 'gitea';
+ // primary=local restricts login to local accounts → OAuth providers off.
+ if (primary === 'local' && isLocalEnabled(authConfig)) return false;
return true;
}
+/** Local email+password login is turned on. */
+export function isLocalEnabled(authConfig: AuthConfig): boolean {
+ return authConfig.local?.enabled === true;
+}
+
+/**
+ * Self-service password change for the authenticated local user. Mount behind
+ * requireAuth + a JSON body parser. Requires the CURRENT password (so a
+ * hijacked session can't silently re-key the account) and only applies to
+ * accounts that already have a local credential — OAuth-only users have none
+ * (adding one is a separate, deferred action). 204 on success.
+ */
+export function buildChangePasswordHandler(repo: Repository): RequestHandler {
+ return function changePassword(req: Request, res: Response): void {
+ const user = req.user as Express.User | undefined;
+ if (!user) { res.status(401).json({ error: 'unauthenticated' }); return; }
+ const { currentPassword, newPassword } = (req.body ?? {}) as {
+ currentPassword?: unknown;
+ newPassword?: unknown;
+ };
+ if (typeof newPassword !== 'string' || newPassword.length < 8) {
+ res.status(400).json({ error: 'new password must be at least 8 characters' });
+ return;
+ }
+ if (!repo.hasLocalCredential(user.id)) {
+ res.status(400).json({ error: 'this account has no local password to change' });
+ return;
+ }
+ if (typeof currentPassword !== 'string' || !repo.verifyLocalPassword(user.id, currentPassword)) {
+ res.status(403).json({ error: 'current password is incorrect' });
+ return;
+ }
+ repo.setLocalPassword(user.id, newPassword);
+ res.status(204).end();
+ };
+}
+
/**
* auth-login.html をレンダリングする。
* primary_provider 設定と各プロバイダの configured 状態に応じて
@@ -84,27 +123,32 @@ function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAU
const raw = readFileSync(path.join(__authDirname, 'auth-login.html'), 'utf-8');
const googleConfigured = isProviderConfigured(authConfig.providers.google, 'google');
const giteaConfigured = isProviderConfigured(authConfig.providers.gitea, 'gitea');
- // Ignore a primaryProvider that points to an unconfigured provider — otherwise
- // it would hide the only working login button and lock the operator out.
+ const localEnabled = isLocalEnabled(authConfig);
+ const allowSignup = authConfig.local?.allowSignup === true;
+ // Ignore a primaryProvider that points to an unconfigured/disabled provider —
+ // otherwise it would hide the only working login and lock the operator out.
const primary =
(authConfig.primaryProvider === 'google' && googleConfigured) ||
- (authConfig.primaryProvider === 'gitea' && giteaConfigured)
+ (authConfig.primaryProvider === 'gitea' && giteaConfigured) ||
+ (authConfig.primaryProvider === 'local' && localEnabled)
? authConfig.primaryProvider
: undefined;
- // Decide which buttons to show
+ // Decide which login options to show
let showGoogle: boolean;
let showGitea: boolean;
+ let showLocal: boolean;
if (primary === 'google') {
- showGoogle = googleConfigured;
- showGitea = false;
+ showGoogle = googleConfigured; showGitea = false; showLocal = false;
} else if (primary === 'gitea') {
- showGoogle = false;
- showGitea = giteaConfigured;
+ showGoogle = false; showGitea = giteaConfigured; showLocal = false;
+ } else if (primary === 'local') {
+ showGoogle = false; showGitea = false; showLocal = localEnabled;
} else {
- // No primary specified: show every configured provider
+ // No primary specified: show every configured/enabled option
showGoogle = googleConfigured;
showGitea = giteaConfigured;
+ showLocal = localEnabled;
}
const stripBlock = (html: string, startMarker: string, endMarker: string): string => {
@@ -115,8 +159,15 @@ function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAU
let out = raw;
if (!showGoogle) out = stripBlock(out, 'GOOGLE_BUTTON_START', 'GOOGLE_BUTTON_END');
if (!showGitea) out = stripBlock(out, 'GITEA_BUTTON_START', 'GITEA_BUTTON_END');
- // Hide divider unless both buttons are visible
+ // OAuth-only divider: only when BOTH oauth buttons are visible
if (!(showGoogle && showGitea)) out = stripBlock(out, 'DIVIDER_START', 'DIVIDER_END');
+ // Local form + signup + its divider
+ if (!showLocal) out = stripBlock(out, 'LOCAL_FORM_START', 'LOCAL_FORM_END');
+ else {
+ if (!allowSignup) out = stripBlock(out, 'LOCAL_SIGNUP_START', 'LOCAL_SIGNUP_END');
+ // Divider above the local form only when an OAuth button sits above it
+ if (!(showGoogle || showGitea)) out = stripBlock(out, 'LOCAL_DIVIDER_START', 'LOCAL_DIVIDER_END');
+ }
// Branding placeholders
out = out
@@ -465,6 +516,7 @@ function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
// ── Auth Router ───────────────────────────────────────────────────────────────
function createAuthRouter(
+ repo: Repository,
authConfig: AuthConfig,
getBranding?: () => LoginBranding,
): Router {
@@ -531,6 +583,65 @@ function createAuthRouter(
);
}
+ // Local accounts (email + password)
+ if (isLocalEnabled(authConfig)) {
+ const parseBody = [express.urlencoded({ extended: false }), express.json()];
+
+ const toExpressUser = (u: User): Express.User => ({
+ ...u,
+ orgIds: [],
+ defaultVisibility: u.defaultVisibility ?? 'private',
+ defaultVisibilityOrgId: u.defaultVisibilityOrgId ?? null,
+ });
+
+ const readCreds = (req: Request): { email: string; password: string } | null => {
+ const b = (req.body ?? {}) as { email?: unknown; password?: unknown };
+ if (typeof b.email !== 'string' || typeof b.password !== 'string') return null;
+ const email = b.email.trim();
+ if (!email || !b.password) return null;
+ return { email, password: b.password };
+ };
+
+ // Login: verify password, then establish the passport session via req.login.
+ router.post('/local', ...parseBody, (req: Request, res: Response, next: NextFunction) => {
+ const creds = readCreds(req);
+ if (!creds) { res.redirect('/auth/login?error=invalid'); return; }
+ const user = repo.getUserByEmail(creds.email);
+ // Verify even when the user is missing is not needed; getUserByEmail is the
+ // gate. Wrong password and unknown email both surface as the same error.
+ if (!user || !repo.verifyLocalPassword(user.id, creds.password)) {
+ res.redirect('/auth/login?error=credentials');
+ return;
+ }
+ if (user.status === 'disabled') { res.redirect('/auth/login?error=disabled'); return; }
+ req.login(toExpressUser(user), (err) => {
+ if (err) { next(err); return; }
+ res.redirect(user.status === 'active' ? '/' : '/auth/pending');
+ });
+ });
+
+ // Self-signup (opt-in): creates a pending user an admin must approve.
+ if (authConfig.local?.allowSignup) {
+ router.post('/local/signup', ...parseBody, (req: Request, res: Response, next: NextFunction) => {
+ const creds = readCreds(req);
+ if (!creds) { res.redirect('/auth/login?error=invalid'); return; }
+ if (creds.password.length < 8) { res.redirect('/auth/login?error=weak'); return; }
+ let user;
+ try {
+ user = repo.createLocalUser({ email: creds.email, password: creds.password, role: 'user', status: 'pending' });
+ } catch {
+ // Most likely the email is already registered. Don't disclose which.
+ res.redirect('/auth/login?error=signup');
+ return;
+ }
+ req.login(toExpressUser(user), (err) => {
+ if (err) { next(err); return; }
+ res.redirect('/auth/pending');
+ });
+ });
+ }
+ }
+
// ログアウト
router.get('/logout', (req, res, next) => {
req.logout((err) => {
@@ -624,7 +735,7 @@ export function setupAuth(
registerGiteaStrategy(repo, authConfig);
// 認証ルーター
- const authRouter = createAuthRouter(authConfig, getBranding);
+ const authRouter = createAuthRouter(repo, authConfig, getBranding);
const passportInit = passport.initialize();
const passportSession = passport.session();
diff --git a/src/bridge/server.ts b/src/bridge/server.ts
index 72d344c..a0258ba 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 } from './auth.js';
+import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler } from './auth.js';
import { canUserSeeTask } from './visibility.js';
import { mountAdminApi } from './admin-api.js';
import { createAdminGatewayApi } from './admin-gateway-api.js';
@@ -238,14 +238,20 @@ export function createCoreServer(opts: CoreServerOptions): {
const _hasAnyField = (p?: { clientId?: string; clientSecret?: string; callbackUrl?: string; baseUrl?: string }) =>
!!(p?.clientId || p?.clientSecret || p?.callbackUrl || p?.baseUrl);
const authIntended = _hasAnyField(_authProviders?.google) || _hasAnyField(_authProviders?.gitea);
- if (authIntended && !authUsable) {
+ // Local email+password is a first-class auth mode: when enabled, auth is
+ // active even without any OAuth provider.
+ const localEnabled = !!opts.authConfig && isLocalEnabled(opts.authConfig);
+ // Fail closed on a partial OAuth config ONLY when it would otherwise drop to
+ // no-auth. If local auth is on, auth is already active (no fail-open), so a
+ // half-configured OAuth provider just stays inactive rather than aborting boot.
+ if (authIntended && !authUsable && !localEnabled) {
throw new Error(
'[auth] auth is partially configured: a provider has a client_id but is missing ' +
'client_secret / callback_url (Gitea also needs base_url). Refusing to start in an ' +
'insecure no-auth state — complete the provider config or remove it from config.yaml.',
);
}
- const authActive = authUsable;
+ const authActive = authUsable || localEnabled;
if (!authActive) {
// No-auth single-user mode: per-user rows are owned by the synthetic
// 'local' id, and several tables FK to users(id) (ssh_user_deks,
@@ -256,6 +262,15 @@ export function createCoreServer(opts: CoreServerOptions): {
let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined;
if (authActive) {
+ // Idempotently seed the shared `local` system admin (id='local', the same
+ // owner the no-auth path uses) so an existing single-user / no-auth
+ // deployment gains a login mid-stream and keeps its `local`-owned data.
+ const bootstrap = opts.authConfig?.local?.bootstrapAdmin;
+ if (localEnabled && bootstrap?.email && bootstrap?.password) {
+ repo.upsertLocalSystemAdmin({ email: bootstrap.email, password: bootstrap.password });
+ logger.info(`[auth] seeded local system admin id=local email=${bootstrap.email}`);
+ }
+
const auth = setupAuth(
repo,
opts.authConfig!,
@@ -280,6 +295,9 @@ export function createCoreServer(opts: CoreServerOptions): {
res.json(req.user);
});
+ // Self-service password change for local accounts (see auth.ts).
+ app.post('/api/auth/password', requireAuth, express.json(), buildChangePasswordHandler(repo));
+
// Protect all API routes (except /api/version and /health)
app.use('/api/local', requireAuth);
app.use('/api/repos', requireAuth);
@@ -304,7 +322,7 @@ export function createCoreServer(opts: CoreServerOptions): {
// Admin user management API (always mounted; protected by requireAdmin when auth is active)
app.use('/api/admin', express.json());
- mountAdminApi(app, repo, authActive);
+ mountAdminApi(app, repo, authActive, loadConfig()?.userFolderRoot ?? './data/users');
// AAO Gateway Phase 2a: admin-only CRUD over gateway_virtual_keys.
// Enabled regardless of gateway.enabled so an admin can prep keys
diff --git a/src/config.ts b/src/config.ts
index 88f62f8..e2d8689 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -309,7 +309,24 @@ export interface AuthProviderConfig {
baseUrl?: string; // Gitea 用
}
-export type PrimaryAuthProvider = 'google' | 'gitea';
+export type PrimaryAuthProvider = 'google' | 'gitea' | 'local';
+
+/**
+ * Local-account auth (email + password). A third option between OAuth-only and
+ * open no-auth. See docs/superpowers/plans/2026-06-09-local-auth.md.
+ */
+export interface LocalAuthConfig {
+ /** Turn local login on. When true, authActive becomes true even without OAuth. */
+ enabled?: boolean;
+ /** Allow self-signup (creates a pending user an admin must approve). */
+ allowSignup?: boolean;
+ /**
+ * Seed (idempotently, at startup) the shared `local` system admin under the
+ * same owner id the no-auth path uses, so existing single-user-mode data is
+ * carried over. Re-running updates the password.
+ */
+ bootstrapAdmin?: { email: string; password: string };
+}
export interface AuthConfig {
sessionSecret: string;
@@ -321,6 +338,8 @@ export interface AuthConfig {
google?: AuthProviderConfig;
gitea?: AuthProviderConfig;
};
+ /** Local email+password accounts. Undefined = disabled. */
+ local?: LocalAuthConfig;
}
export interface BrandingConfig {
diff --git a/src/db/migrate.ts b/src/db/migrate.ts
index 64d1060..209375b 100644
--- a/src/db/migrate.ts
+++ b/src/db/migrate.ts
@@ -546,5 +546,14 @@ function migratePushNotificationsTables(db: Database.Database): void {
v1_migrated INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+
+ -- Local-auth credentials (email + password accounts). Idempotent add so
+ -- an existing no-auth / OAuth-only deployment gains local login on upgrade.
+ CREATE TABLE IF NOT EXISTS local_credentials (
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ password_hash TEXT NOT NULL,
+ salt TEXT NOT NULL,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
`);
}
diff --git a/src/db/repository-local-auth.test.ts b/src/db/repository-local-auth.test.ts
new file mode 100644
index 0000000..063697e
--- /dev/null
+++ b/src/db/repository-local-auth.test.ts
@@ -0,0 +1,126 @@
+/**
+ * Local-auth repository layer: password hashing, local account creation,
+ * the shared `local` system admin, and the user-deletion guards.
+ *
+ * See docs/superpowers/plans/2026-06-09-local-auth.md.
+ */
+import { afterEach, describe, it, expect, beforeEach } 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-auth', () => {
+ let tempDir = '';
+ let repo: Repository;
+
+ beforeEach(() => {
+ tempDir = mkdtempSync(join(tmpdir(), 'maestro-localauth-'));
+ repo = new Repository(join(tempDir, 'orchestrator.db'));
+ runMigrations(repo.getDb());
+ });
+
+ afterEach(() => {
+ repo.close();
+ if (tempDir) {
+ rmSync(tempDir, { recursive: true, force: true });
+ tempDir = '';
+ }
+ });
+
+ // ── password hashing ──────────────────────────────────────────
+
+ it('setLocalPassword + verifyLocalPassword round-trips', () => {
+ const u = repo.createUser({ email: 'a@x.com', name: 'A', role: 'user', status: 'active' });
+ repo.setLocalPassword(u.id, 'correct horse battery staple');
+ expect(repo.verifyLocalPassword(u.id, 'correct horse battery staple')).toBe(true);
+ expect(repo.verifyLocalPassword(u.id, 'wrong')).toBe(false);
+ });
+
+ it('verifyLocalPassword returns false for a user with no credential', () => {
+ const u = repo.createUser({ email: 'b@x.com', name: 'B', role: 'user', status: 'active' });
+ expect(repo.verifyLocalPassword(u.id, 'anything')).toBe(false);
+ });
+
+ it('setLocalPassword is idempotent-overwrite (re-set changes the password)', () => {
+ const u = repo.createUser({ email: 'c@x.com', name: 'C', role: 'user', status: 'active' });
+ repo.setLocalPassword(u.id, 'first');
+ repo.setLocalPassword(u.id, 'second');
+ expect(repo.verifyLocalPassword(u.id, 'first')).toBe(false);
+ expect(repo.verifyLocalPassword(u.id, 'second')).toBe(true);
+ });
+
+ it('stores a per-user random salt (two users, same password → different hash)', () => {
+ const u1 = repo.createUser({ email: 'd@x.com', name: 'D', role: 'user', status: 'active' });
+ const u2 = repo.createUser({ email: 'e@x.com', name: 'E', role: 'user', status: 'active' });
+ repo.setLocalPassword(u1.id, 'same');
+ repo.setLocalPassword(u2.id, 'same');
+ const db = repo.getDb();
+ const r1 = db.prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id=?').get(u1.id) as { password_hash: string; salt: string };
+ const r2 = db.prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id=?').get(u2.id) as { password_hash: string; salt: string };
+ expect(r1.salt).not.toBe(r2.salt);
+ expect(r1.password_hash).not.toBe(r2.password_hash);
+ });
+
+ // ── createLocalUser ───────────────────────────────────────────
+
+ it('createLocalUser creates a pending user with a local identity + password', () => {
+ const u = repo.createLocalUser({ email: 'new@x.com', password: 'pw12345', role: 'user', status: 'pending' });
+ expect(u.email).toBe('new@x.com');
+ expect(u.status).toBe('pending');
+ expect(repo.verifyLocalPassword(u.id, 'pw12345')).toBe(true);
+ const idn = repo.getDb().prepare("SELECT provider, provider_id FROM oauth_accounts WHERE user_id=? AND provider='local'").get(u.id) as { provider: string; provider_id: string };
+ expect(idn.provider).toBe('local');
+ expect(idn.provider_id).toBe('new@x.com');
+ });
+
+ it('createLocalUser REJECTS an email that already exists (no account takeover)', () => {
+ repo.createUser({ email: 'taken@x.com', name: 'T', role: 'user', status: 'active' });
+ expect(() => repo.createLocalUser({ email: 'taken@x.com', password: 'pw', role: 'user', status: 'pending' }))
+ .toThrow(/exist/i);
+ });
+
+ // ── upsertLocalSystemAdmin (shared 'local' identity) ──────────
+
+ it('upsertLocalSystemAdmin creates the shared id=local admin (active)', () => {
+ const u = repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'adminpw' });
+ expect(u.id).toBe('local');
+ expect(u.role).toBe('admin');
+ expect(u.status).toBe('active');
+ expect(repo.verifyLocalPassword('local', 'adminpw')).toBe(true);
+ });
+
+ it('upsertLocalSystemAdmin is idempotent and keeps id=local across re-seeds', () => {
+ repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'pw1' });
+ const again = repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'pw2' });
+ expect(again.id).toBe('local');
+ // password updated on re-seed
+ expect(repo.verifyLocalPassword('local', 'pw2')).toBe(true);
+ // exactly one users row with id=local
+ const cnt = repo.getDb().prepare("SELECT COUNT(*) c FROM users WHERE id='local'").get() as { c: number };
+ expect(cnt.c).toBe(1);
+ });
+
+ it('local-owned data survives because the admin IS the local owner (id=local)', () => {
+ // Simulate no-auth data owned by 'local', then seed the admin onto it.
+ repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'pw' });
+ expect(repo.getUserById('local')?.role).toBe('admin');
+ });
+
+ // ── deleteUser guards ─────────────────────────────────────────
+
+ it('deleteUser REFUSES to delete the local/system user', () => {
+ repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'pw' });
+ expect(() => repo.deleteUser('local')).toThrow(/local|system/i);
+ expect(repo.getUserById('local')).not.toBeNull();
+ });
+
+ it('deleteUser cascades local_credentials (FK ON DELETE CASCADE)', () => {
+ const u = repo.createLocalUser({ email: 'z@x.com', password: 'pw', role: 'user', status: 'active' });
+ expect(repo.verifyLocalPassword(u.id, 'pw')).toBe(true);
+ repo.deleteUser(u.id);
+ const row = repo.getDb().prepare('SELECT 1 FROM local_credentials WHERE user_id=?').get(u.id);
+ expect(row).toBeUndefined();
+ });
+});
diff --git a/src/db/repository.ts b/src/db/repository.ts
index f5740d5..ef2264d 100644
--- a/src/db/repository.ts
+++ b/src/db/repository.ts
@@ -2,7 +2,7 @@ import Database from 'better-sqlite3';
import { readFileSync, rmSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
-import { randomUUID } from 'crypto';
+import { randomUUID, scryptSync, randomBytes, timingSafeEqual } from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../logger.js';
import { buildVisibilityWhere } from '../bridge/visibility.js';
@@ -572,6 +572,14 @@ export interface FindOrCreateByOAuthParams {
avatarUrl?: string;
}
+export interface CreateLocalUserParams {
+ email: string;
+ password: string;
+ role: 'admin' | 'user';
+ status: 'active' | 'pending' | 'disabled';
+ name?: string;
+}
+
export interface GiteaOrgInput {
orgId: string;
orgName: string;
@@ -2477,6 +2485,99 @@ export class Repository {
.run({ now });
}
+ // ── Local auth (email + password) ─────────────────────────────────────
+
+ /** scrypt hash with a fresh per-user salt. Overwrites any existing credential. */
+ setLocalPassword(userId: string, plainPassword: string): void {
+ const salt = randomBytes(16).toString('hex');
+ const hash = scryptSync(plainPassword, salt, 64).toString('hex');
+ const now = new Date().toISOString();
+ this.db
+ .prepare(
+ `INSERT INTO local_credentials (user_id, password_hash, salt, updated_at)
+ VALUES (@userId, @hash, @salt, @now)
+ ON CONFLICT(user_id) DO UPDATE SET password_hash=@hash, salt=@salt, updated_at=@now`,
+ )
+ .run({ userId, hash, salt, now });
+ }
+
+ /** Constant-time verify. False when the user has no local credential. */
+ verifyLocalPassword(userId: string, plainPassword: string): boolean {
+ const row = this.db
+ .prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id = ?')
+ .get(userId) as { password_hash: string; salt: string } | undefined;
+ if (!row) return false;
+ const expected = Buffer.from(row.password_hash, 'hex');
+ const actual = scryptSync(plainPassword, row.salt, expected.length);
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
+ }
+
+ hasLocalCredential(userId: string): boolean {
+ return !!this.db.prepare('SELECT 1 FROM local_credentials WHERE user_id = ?').get(userId);
+ }
+
+ /**
+ * Create a brand-new local account (self-signup or admin-created). The email
+ * MUST be unused: attaching a password to an existing account would be an
+ * account-takeover vector, so we reject instead of linking. Linking a local
+ * credential to an existing OAuth account is a separate, authenticated action
+ * (not v1 signup).
+ */
+ createLocalUser(params: CreateLocalUserParams): User {
+ if (this.getUserByEmail(params.email)) {
+ throw new Error(`createLocalUser: a user with email ${params.email} already exists`);
+ }
+ const user = this.createUser({
+ email: params.email,
+ name: params.name ?? params.email,
+ role: params.role,
+ status: params.status,
+ });
+ this.db
+ .prepare(
+ `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at)
+ VALUES (@id, @userId, 'local', @providerId, @now)`,
+ )
+ .run({ id: uuidv4(), userId: user.id, providerId: params.email, now: new Date().toISOString() });
+ this.setLocalPassword(user.id, params.password);
+ return user;
+ }
+
+ /**
+ * Idempotently seed the shared system admin under the fixed id `local` — the
+ * same owner the no-auth path synthesizes. This makes all pre-existing
+ * `local`-owned data belong to the logged-in admin once local auth is turned
+ * on, and lets an existing no-auth deployment gain a login mid-stream.
+ * Re-running updates the password and keeps role=admin/status=active.
+ */
+ upsertLocalSystemAdmin(params: { email: string; password: string; name?: string }): User {
+ const LOCAL_ID = 'local';
+ const now = new Date().toISOString();
+ const existing = this.getUserById(LOCAL_ID);
+ if (!existing) {
+ this.db
+ .prepare(
+ `INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at)
+ VALUES (@id, @email, @name, NULL, 'admin', 'active', @now, @now)`,
+ )
+ .run({ id: LOCAL_ID, email: params.email, name: params.name ?? 'Local Admin', now });
+ } else {
+ this.db
+ .prepare(`UPDATE users SET email=@email, role='admin', status='active', updated_at=@now WHERE id=@id`)
+ .run({ id: LOCAL_ID, email: params.email, now });
+ }
+ this.db
+ .prepare(
+ `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at)
+ VALUES (@id, @userId, 'local', @providerId, @now)`,
+ )
+ .run({ id: uuidv4(), userId: LOCAL_ID, providerId: params.email, now });
+ this.setLocalPassword(LOCAL_ID, params.password);
+ const user = this.getUserById(LOCAL_ID);
+ if (!user) throw new Error('upsertLocalSystemAdmin: failed to retrieve local admin');
+ return user;
+ }
+
getUserById(id: string): User | null {
const row = this.db
.prepare('SELECT * FROM users WHERE id = ?')
@@ -2609,6 +2710,12 @@ export class Repository {
}
deleteUser(id: string): void {
+ // Never delete the shared `local` system/admin user: it is the no-auth
+ // fallback owner and owns all single-user-mode data. Deleting it would
+ // break no-auth mode and orphan every `local`-owned task/job/folder.
+ if (id === 'local') {
+ throw new Error('cannot delete the local/system user');
+ }
this.db.prepare('DELETE FROM users WHERE id = ?').run(id);
}
diff --git a/src/db/schema.sql b/src/db/schema.sql
index 9d4e4de..7de7df7 100644
--- a/src/db/schema.sql
+++ b/src/db/schema.sql
@@ -163,6 +163,16 @@ CREATE TABLE IF NOT EXISTS oauth_accounts (
UNIQUE(provider, provider_id)
);
+-- Auth: local-account credentials (email + password). One row per user that
+-- has a local password; OAuth-only users have none. scrypt hash + per-user
+-- salt. Cascades with the user so deletion leaves no orphaned credential.
+CREATE TABLE IF NOT EXISTS local_credentials (
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ password_hash TEXT NOT NULL,
+ salt TEXT NOT NULL,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
-- Auth: sessions (express-session)
CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
diff --git a/src/user-folder/delete-folder.test.ts b/src/user-folder/delete-folder.test.ts
new file mode 100644
index 0000000..65ec33a
--- /dev/null
+++ b/src/user-folder/delete-folder.test.ts
@@ -0,0 +1,45 @@
+/**
+ * deleteUserFolder — removes a user's filesystem folder on account deletion.
+ *
+ * The DB cascade-deletes owned rows, but the user folder
+ * ({root}/{ownerId}/...) is on disk and was previously orphaned on delete.
+ * See docs/superpowers/plans/2026-06-09-local-auth.md.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'fs';
+import { join } from 'path';
+import { tmpdir } from 'os';
+import { deleteUserFolder, ensureUserFolder } from './paths.js';
+
+describe('deleteUserFolder', () => {
+ let root = '';
+ beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'maestro-uf-')); });
+ afterEach(() => { if (root) { rmSync(root, { recursive: true, force: true }); root = ''; } });
+
+ it('removes the entire user folder', () => {
+ const dir = ensureUserFolder(root, 'alice');
+ writeFileSync(join(dir, 'notes', 'x.md'), 'hi');
+ expect(existsSync(dir)).toBe(true);
+ deleteUserFolder(root, 'alice');
+ expect(existsSync(dir)).toBe(false);
+ });
+
+ it('NEVER removes the local/system folder (shared no-auth owner)', () => {
+ const dir = ensureUserFolder(root, 'local');
+ deleteUserFolder(root, 'local');
+ expect(existsSync(dir)).toBe(true); // preserved
+ });
+
+ it('is a no-op when the folder does not exist', () => {
+ expect(() => deleteUserFolder(root, 'ghost')).not.toThrow();
+ });
+
+ it('refuses path-traversal ids (no escape outside root)', () => {
+ // userRoot rejects these; deleteUserFolder must not delete anything outside.
+ const sibling = mkdtempSync(join(tmpdir(), 'maestro-sibling-'));
+ writeFileSync(join(sibling, 'keep.txt'), 'keep');
+ expect(() => deleteUserFolder(root, '../../etc')).not.toThrow();
+ expect(existsSync(join(sibling, 'keep.txt'))).toBe(true);
+ rmSync(sibling, { recursive: true, force: true });
+ });
+});
diff --git a/src/user-folder/paths.ts b/src/user-folder/paths.ts
index 5a863aa..a88b9f6 100644
--- a/src/user-folder/paths.ts
+++ b/src/user-folder/paths.ts
@@ -1,6 +1,9 @@
-import { mkdirSync, chmodSync, existsSync, readFileSync, writeFileSync, unlinkSync, statSync, openSync, readSync, closeSync } from 'fs';
+import { mkdirSync, chmodSync, existsSync, readFileSync, writeFileSync, unlinkSync, statSync, openSync, readSync, closeSync, rmSync } from 'fs';
import { resolve, join, relative, isAbsolute } from 'path';
+/** The shared no-auth / local-auth system owner. Its folder is never deleted. */
+export const LOCAL_SYSTEM_OWNER_ID = 'local';
+
const USER_AGENTS_MAX_BYTES = 64 * 1024;
export const USER_SUBDIRS = ['scripts', 'browser-macros', 'templates', 'recordings', 'trash', 'memory', 'pets', 'notes'] as const;
@@ -29,6 +32,29 @@ export function ensureUserFolder(rootDir: string, ownerId: string): string {
return root;
}
+/**
+ * Recursively remove a user's entire folder ({root}/{ownerId}). Called when an
+ * account is deleted so its scripts/macros/recordings/notes/memory/pets don't
+ * orphan on disk (the DB cascade-deletes rows but not files).
+ *
+ * Guards:
+ * - the `local` system owner is NEVER removed (shared no-auth / local-auth
+ * admin; deleting it would wipe the single-user deployment's data)
+ * - an invalid / traversal ownerId resolves to nothing and is a silent no-op
+ * (userRoot throws on '/', '\\', '\0', absolute paths)
+ * - missing folder is a no-op (rmSync force)
+ */
+export function deleteUserFolder(rootDir: string, ownerId: string): void {
+ if (ownerId === LOCAL_SYSTEM_OWNER_ID) return;
+ let root: string;
+ try {
+ root = userRoot(rootDir, ownerId);
+ } catch {
+ return; // invalid ownerId — never delete anything outside a valid user root
+ }
+ rmSync(root, { recursive: true, force: true });
+}
+
export function resolveUserSubdir(
rootDir: string,
ownerId: string,
diff --git a/ui/src/components/admin/LocalUserDialogs.tsx b/ui/src/components/admin/LocalUserDialogs.tsx
new file mode 100644
index 0000000..d57e205
--- /dev/null
+++ b/ui/src/components/admin/LocalUserDialogs.tsx
@@ -0,0 +1,186 @@
+import { useState, type FormEvent } from 'react';
+
+/**
+ * Local-account dialogs:
+ * - CreateLocalUserDialog — admin creates an email+password account (active)
+ * - ResetPasswordDialog — admin resets another user's local password
+ * - ChangePasswordDialog — the signed-in user changes their own password
+ *
+ * Self-contained (own fetch + state); callers pass close/success callbacks.
+ * See docs/superpowers/plans/2026-06-09-local-auth.md.
+ */
+
+const overlay = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4';
+const panel = 'w-full max-w-sm bg-canvas border border-hairline rounded-lg shadow-xl p-5';
+const inputCls = 'w-full 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';
+const labelCls = 'block text-2xs font-medium text-slate-500 mb-1';
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return {label}{children}
;
+}
+
+function ErrorNote({ msg }: { msg: string | null }) {
+ if (!msg) return null;
+ return {msg}
;
+}
+
+function Actions({ onClose, busy, submitLabel }: { onClose: () => void; busy: boolean; submitLabel: string }) {
+ return (
+
+
+
+
+ );
+}
+
+export function CreateLocalUserDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [role, setRole] = useState<'user' | 'admin'>('user');
+ const [err, setErr] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const submit = async (e: FormEvent) => {
+ e.preventDefault();
+ setErr(null);
+ if (password.length < 8) { setErr('パスワードは8文字以上にしてください'); return; }
+ setBusy(true);
+ try {
+ const res = await fetch('/api/admin/users', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: email.trim(), password, role }),
+ });
+ if (res.status === 409) { setErr('そのメールアドレスは既に登録されています'); return; }
+ if (!res.ok) { setErr('作成に失敗しました'); return; }
+ onCreated();
+ onClose();
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+export function ResetPasswordDialog({ userId, email, onClose }: { userId: string; email: string; onClose: () => void }) {
+ const [password, setPassword] = useState('');
+ const [err, setErr] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [done, setDone] = useState(false);
+
+ const submit = async (e: FormEvent) => {
+ e.preventDefault();
+ setErr(null);
+ if (password.length < 8) { setErr('パスワードは8文字以上にしてください'); return; }
+ setBusy(true);
+ try {
+ const res = await fetch(`/api/admin/users/${userId}/password`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ password }),
+ });
+ if (!res.ok) { setErr('リセットに失敗しました'); return; }
+ setDone(true);
+ setTimeout(onClose, 900);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+export function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
+ const [current, setCurrent] = useState('');
+ const [next, setNext] = useState('');
+ const [err, setErr] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [done, setDone] = useState(false);
+
+ const submit = async (e: FormEvent) => {
+ e.preventDefault();
+ setErr(null);
+ if (next.length < 8) { setErr('新しいパスワードは8文字以上にしてください'); return; }
+ setBusy(true);
+ try {
+ const res = await fetch('/api/auth/password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ currentPassword: current, newPassword: next }),
+ });
+ if (res.status === 403) { setErr('現在のパスワードが正しくありません'); return; }
+ if (res.status === 400) { setErr('このアカウントにはローカルパスワードがありません'); return; }
+ if (!res.ok) { setErr('変更に失敗しました'); return; }
+ setDone(true);
+ setTimeout(onClose, 900);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/layout/TopBar.tsx b/ui/src/components/layout/TopBar.tsx
index a353bd2..29ed10d 100644
--- a/ui/src/components/layout/TopBar.tsx
+++ b/ui/src/components/layout/TopBar.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import type { PageId } from '../../lib/urlState';
import type { AuthUser } from '../../App';
import { ThemeToggle } from './ThemeToggle';
+import { ChangePasswordDialog } from '../admin/LocalUserDialogs';
interface TopBarProps {
currentPage: PageId;
@@ -74,6 +75,7 @@ export function TopBar({
}: TopBarProps) {
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
+ const [showPwChange, setShowPwChange] = useState(false);
return (
+
)}
+ {showPwChange && setShowPwChange(false)} />}
diff --git a/ui/src/pages/UsersPage.tsx b/ui/src/pages/UsersPage.tsx
index b628e38..b828f87 100644
--- a/ui/src/pages/UsersPage.tsx
+++ b/ui/src/pages/UsersPage.tsx
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { EmptyState } from '../components/shared/EmptyState';
import { StatChip } from '../components/shared/StatChip';
+import { CreateLocalUserDialog, ResetPasswordDialog } from '../components/admin/LocalUserDialogs';
interface UserOrg {
orgId: string;
@@ -125,6 +126,8 @@ export function UsersPage() {
const [filter, setFilter] = useState('all');
const [search, setSearch] = useState('');
const [activeId, setActiveId] = useState(null);
+ const [showCreate, setShowCreate] = useState(false);
+ const [resetUser, setResetUser] = useState(null);
// Mobile single-column flow: list ↔ detail toggle. On sm+ both panes
// are visible side-by-side and this flag is ignored.
const [mobileShowDetail, setMobileShowDetail] = useState(false);
@@ -199,6 +202,7 @@ export function UsersPage() {
onSelect={handleSelect}
onClearFilters={() => { setSearch(''); setFilter('all'); }}
isLoading={isLoading}
+ onCreate={() => setShowCreate(true)}
/>
@@ -206,6 +210,7 @@ export function UsersPage() {
user={active}
onMobileBack={handleMobileBack}
onPatch={(id, body) => patchMutation.mutate({ id, body })}
+ onResetPassword={(u) => setResetUser(u)}
onDelete={(id) => {
if (confirm('本当にこのユーザーを削除しますか?')) {
deleteMutation.mutate(id);
@@ -215,6 +220,20 @@ export function UsersPage() {
}}
/>
+
+ {showCreate && (
+ setShowCreate(false)}
+ onCreated={() => qc.invalidateQueries({ queryKey: ['admin', 'users'] })}
+ />
+ )}
+ {resetUser && (
+ setResetUser(null)}
+ />
+ )}
);
}
@@ -230,11 +249,12 @@ interface UserListPaneProps {
onSelect: (id: string) => void;
onClearFilters: () => void;
isLoading: boolean;
+ onCreate: () => void;
}
function UserListPane({
users, activeId, counts, filter, setFilter, search, setSearch,
- onSelect, onClearFilters, isLoading,
+ onSelect, onClearFilters, isLoading, onCreate,
}: UserListPaneProps) {
const hasFilters = !!search || filter !== 'all';
@@ -255,6 +275,13 @@ function UserListPane({
{counts.pending} 件 承認待ち
>
)}
+
@@ -312,7 +339,7 @@ function UserListPane({
)
)}
@@ -365,11 +392,12 @@ interface UserDetailPaneProps {
user: UserRecord | null;
onPatch: (id: string, body: Record
) => void;
onDelete: (id: string) => void;
+ onResetPassword: (user: UserRecord) => void;
/** Mobile-only callback to return to the list pane. Hidden on sm+. */
onMobileBack?: () => void;
}
-function UserDetailPane({ user, onPatch, onDelete, onMobileBack }: UserDetailPaneProps) {
+function UserDetailPane({ user, onPatch, onDelete, onResetPassword, onMobileBack }: UserDetailPaneProps) {
if (!user) {
return (
@@ -443,11 +471,20 @@ function UserDetailPane({ user, onPatch, onDelete, onMobileBack }: UserDetailPan
)}
+ {user.id !== 'local' && (
+
+ )}