sync: update from private repo (88dd58b)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
2bab882d08
commit
2ec9853655
@ -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 影響なし。
|
||||
|
||||
145
src/bridge/admin-api.local.test.ts
Normal file
145
src/bridge/admin-api.local.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@ -325,6 +325,33 @@
|
||||
</a>
|
||||
<!-- GITEA_BUTTON_END -->
|
||||
|
||||
<!-- LOCAL_FORM_START -->
|
||||
<!-- LOCAL_DIVIDER_START -->
|
||||
<div class="divider">または</div>
|
||||
<!-- LOCAL_DIVIDER_END -->
|
||||
<form method="post" action="/auth/local" style="display:flex;flex-direction:column;gap:10px;margin-bottom:8px;">
|
||||
<input type="email" name="email" placeholder="メールアドレス" required autocomplete="username"
|
||||
style="padding:11px 12px;border:1px solid #d0d5dd;border-radius:8px;font-size:14px;width:100%;box-sizing:border-box;">
|
||||
<input type="password" name="password" placeholder="パスワード" required autocomplete="current-password"
|
||||
style="padding:11px 12px;border:1px solid #d0d5dd;border-radius:8px;font-size:14px;width:100%;box-sizing:border-box;">
|
||||
<button type="submit" class="oauth-button" style="justify-content:center;background:#111827;color:#fff;border:none;cursor:pointer;">
|
||||
メールアドレスでログイン
|
||||
</button>
|
||||
</form>
|
||||
<!-- LOCAL_SIGNUP_START -->
|
||||
<div class="divider">アカウントが無い場合</div>
|
||||
<form method="post" action="/auth/local/signup" style="display:flex;flex-direction:column;gap:10px;">
|
||||
<input type="email" name="email" placeholder="メールアドレス" required autocomplete="username"
|
||||
style="padding:11px 12px;border:1px solid #d0d5dd;border-radius:8px;font-size:14px;width:100%;box-sizing:border-box;">
|
||||
<input type="password" name="password" placeholder="パスワード(8文字以上)" minlength="8" required autocomplete="new-password"
|
||||
style="padding:11px 12px;border:1px solid #d0d5dd;border-radius:8px;font-size:14px;width:100%;box-sizing:border-box;">
|
||||
<button type="submit" class="oauth-button" style="justify-content:center;background:#fff;color:#111827;border:1px solid #d0d5dd;cursor:pointer;">
|
||||
新規登録(管理者の承認後に利用可)
|
||||
</button>
|
||||
</form>
|
||||
<!-- LOCAL_SIGNUP_END -->
|
||||
<!-- LOCAL_FORM_END -->
|
||||
|
||||
<p class="footer-note">
|
||||
ログインすることで、利用規約とプライバシーポリシーに<br>同意したものとみなされます。
|
||||
</p>
|
||||
|
||||
137
src/bridge/auth.local.test.ts
Normal file
137
src/bridge/auth.local.test.ts
Normal file
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
126
src/db/repository-local-auth.test.ts
Normal file
126
src/db/repository-local-auth.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
45
src/user-folder/delete-folder.test.ts
Normal file
45
src/user-folder/delete-folder.test.ts
Normal file
@ -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 });
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
186
ui/src/components/admin/LocalUserDialogs.tsx
Normal file
186
ui/src/components/admin/LocalUserDialogs.tsx
Normal file
@ -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 <div className="mb-3"><span className={labelCls}>{label}</span>{children}</div>;
|
||||
}
|
||||
|
||||
function ErrorNote({ msg }: { msg: string | null }) {
|
||||
if (!msg) return null;
|
||||
return <p className="text-2xs text-red-600 bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 rounded px-2 py-1 mb-3">{msg}</p>;
|
||||
}
|
||||
|
||||
function Actions({ onClose, busy, submitLabel }: { onClose: () => void; busy: boolean; submitLabel: string }) {
|
||||
return (
|
||||
<div className="flex justify-end gap-2 mt-1">
|
||||
<button type="button" onClick={onClose} className="px-3 h-8 rounded-md text-xs font-medium border border-hairline text-slate-700 hover:bg-surface">
|
||||
キャンセル
|
||||
</button>
|
||||
<button type="submit" disabled={busy} className="px-3 h-8 rounded-md text-xs font-semibold bg-accent text-white disabled:opacity-50 hover:opacity-90">
|
||||
{busy ? '...' : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<div className={overlay} onClick={onClose}>
|
||||
<form className={panel} onClick={e => e.stopPropagation()} onSubmit={submit}>
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-4">ローカルユーザーを作成</h3>
|
||||
<ErrorNote msg={err} />
|
||||
<Field label="メールアドレス">
|
||||
<input type="email" required value={email} onChange={e => setEmail(e.target.value)} className={inputCls} autoComplete="off" />
|
||||
</Field>
|
||||
<Field label="初期パスワード(8文字以上)">
|
||||
<input type="password" required minLength={8} value={password} onChange={e => setPassword(e.target.value)} className={inputCls} autoComplete="new-password" />
|
||||
</Field>
|
||||
<Field label="ロール">
|
||||
<select value={role} onChange={e => setRole(e.target.value as 'user' | 'admin')} className={inputCls}>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</Field>
|
||||
<p className="text-2xs text-slate-500 mb-3">作成したアカウントは即座に有効(active)になります。</p>
|
||||
<Actions onClose={onClose} busy={busy} submitLabel="作成" />
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResetPasswordDialog({ userId, email, onClose }: { userId: string; email: string; onClose: () => void }) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState<string | null>(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 (
|
||||
<div className={overlay} onClick={onClose}>
|
||||
<form className={panel} onClick={e => e.stopPropagation()} onSubmit={submit}>
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-1">パスワードをリセット</h3>
|
||||
<p className="text-2xs text-slate-500 mb-4 truncate">{email}</p>
|
||||
{done ? (
|
||||
<p className="text-xs text-emerald-700 dark:text-emerald-300">リセットしました。該当ユーザーのセッションは無効化されます。</p>
|
||||
) : (
|
||||
<>
|
||||
<ErrorNote msg={err} />
|
||||
<Field label="新しいパスワード(8文字以上)">
|
||||
<input type="password" required minLength={8} value={password} onChange={e => setPassword(e.target.value)} className={inputCls} autoComplete="new-password" />
|
||||
</Field>
|
||||
<Actions onClose={onClose} busy={busy} submitLabel="リセット" />
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
|
||||
const [current, setCurrent] = useState('');
|
||||
const [next, setNext] = useState('');
|
||||
const [err, setErr] = useState<string | null>(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 (
|
||||
<div className={overlay} onClick={onClose}>
|
||||
<form className={panel} onClick={e => e.stopPropagation()} onSubmit={submit}>
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-4">パスワードを変更</h3>
|
||||
{done ? (
|
||||
<p className="text-xs text-emerald-700 dark:text-emerald-300">変更しました。</p>
|
||||
) : (
|
||||
<>
|
||||
<ErrorNote msg={err} />
|
||||
<Field label="現在のパスワード">
|
||||
<input type="password" required value={current} onChange={e => setCurrent(e.target.value)} className={inputCls} autoComplete="current-password" />
|
||||
</Field>
|
||||
<Field label="新しいパスワード(8文字以上)">
|
||||
<input type="password" required minLength={8} value={next} onChange={e => setNext(e.target.value)} className={inputCls} autoComplete="new-password" />
|
||||
</Field>
|
||||
<Actions onClose={onClose} busy={busy} submitLabel="変更" />
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div
|
||||
@ -170,6 +172,13 @@ export function TopBar({
|
||||
{user.name ?? user.email}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPwChange(true)}
|
||||
className="px-2 py-1 rounded-md text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
|
||||
>
|
||||
パスワード変更
|
||||
</button>
|
||||
<a
|
||||
href="/auth/logout"
|
||||
className="px-2 py-1 rounded-md text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
|
||||
@ -178,6 +187,7 @@ export function TopBar({
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{showPwChange && <ChangePasswordDialog onClose={() => setShowPwChange(false)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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<UserFilter>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [resetUser, setResetUser] = useState<UserRecord | null>(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)}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${mobileShowDetail ? 'flex' : 'hidden sm:flex'} flex-1 min-w-0 bg-canvas flex-col`}>
|
||||
@ -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() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<CreateLocalUserDialog
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={() => qc.invalidateQueries({ queryKey: ['admin', 'users'] })}
|
||||
/>
|
||||
)}
|
||||
{resetUser && (
|
||||
<ResetPasswordDialog
|
||||
userId={resetUser.id}
|
||||
email={resetUser.email}
|
||||
onClose={() => setResetUser(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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({
|
||||
<span><span className="font-semibold text-amber-600">{counts.pending}</span> 件 承認待ち</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
className="ml-auto px-2 h-6 rounded-md text-2xs font-semibold text-accent border border-accent/60 hover:bg-accent-soft transition-colors font-sans"
|
||||
>
|
||||
+ ローカルユーザー
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pb-3 border-b border-hairline">
|
||||
@ -312,7 +339,7 @@ function UserListPane({
|
||||
<EmptyState
|
||||
compact
|
||||
title="ユーザーがいません"
|
||||
hint="OAuth ログインを行うとここに表示されます。"
|
||||
hint="OAuth ログイン、または「+ ローカルユーザー」で作成するとここに表示されます。"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@ -365,11 +392,12 @@ interface UserDetailPaneProps {
|
||||
user: UserRecord | null;
|
||||
onPatch: (id: string, body: Record<string, unknown>) => 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 (
|
||||
<div className="h-full flex items-center justify-center p-10">
|
||||
@ -441,6 +469,14 @@ function UserDetailPane({ user, onPatch, onDelete, onMobileBack }: UserDetailPan
|
||||
有効化
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResetPassword(user)}
|
||||
className="px-3 h-7 rounded-md text-xs font-medium bg-canvas border border-hairline text-slate-700 hover:bg-surface transition-colors whitespace-nowrap"
|
||||
>
|
||||
パスワード
|
||||
</button>
|
||||
{user.id !== 'local' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(user.id)}
|
||||
@ -448,6 +484,7 @@ function UserDetailPane({ user, onPatch, onDelete, onMobileBack }: UserDetailPan
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user