sync: update from private repo (88dd58b)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-09 10:50:27 +00:00
parent 2bab882d08
commit 2ec9853655
17 changed files with 1099 additions and 27 deletions
@@ -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>
);
}
+10
View File
@@ -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>