This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user