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 ( +
+
e.stopPropagation()} onSubmit={submit}> +

ローカルユーザーを作成

+ + + setEmail(e.target.value)} className={inputCls} autoComplete="off" /> + + + setPassword(e.target.value)} className={inputCls} autoComplete="new-password" /> + + + + +

作成したアカウントは即座に有効(active)になります。

+ + +
+ ); +} + +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 ( +
+
e.stopPropagation()} onSubmit={submit}> +

パスワードをリセット

+

{email}

+ {done ? ( +

リセットしました。該当ユーザーのセッションは無効化されます。

+ ) : ( + <> + + + setPassword(e.target.value)} className={inputCls} autoComplete="new-password" /> + + + + )} + +
+ ); +} + +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 ( +
+
e.stopPropagation()} onSubmit={submit}> +

パスワードを変更

+ {done ? ( +

変更しました。

+ ) : ( + <> + + + setCurrent(e.target.value)} className={inputCls} autoComplete="current-password" /> + + + setNext(e.target.value)} className={inputCls} autoComplete="new-password" /> + + + + )} + +
+ ); +} 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' && ( + + )}