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>
+43 -6
View File
@@ -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>