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>
|
||||
|
||||
@@ -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">
|
||||
@@ -443,11 +471,20 @@ function UserDetailPane({ user, onPatch, onDelete, onMobileBack }: UserDetailPan
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(user.id)}
|
||||
className="px-3 h-7 rounded-md text-xs font-medium bg-canvas border border-red-200 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/15 transition-colors whitespace-nowrap"
|
||||
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)}
|
||||
className="px-3 h-7 rounded-md text-xs font-medium bg-canvas border border-red-200 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/15 transition-colors whitespace-nowrap"
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user