This commit is contained in:
@@ -29,6 +29,7 @@ import { GatewayServerForm } from './GatewayServerForm';
|
||||
import { NotesForm } from './NotesForm';
|
||||
import { PushNotificationsForm } from './PushNotificationsForm';
|
||||
import { AuthForm } from './AuthForm';
|
||||
import { OrgsForm } from './OrgsForm';
|
||||
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
@@ -94,6 +95,11 @@ export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
|
||||
if (!isAdmin) {
|
||||
return <div className="max-w-2xl text-sm text-slate-500">この設定は管理者のみ閲覧できます。</div>;
|
||||
}
|
||||
// Local organizations: admin-managed via /api/admin/orgs (not config.yaml),
|
||||
// so render stand-alone without the global save bar.
|
||||
if (section === 'organizations') {
|
||||
return <OrgsForm />;
|
||||
}
|
||||
// Step 8: 'gateway-keys' bookmarks are redirected to 'gateway-server'
|
||||
// by SettingsPage via LEGACY_SECTION_REDIRECT, so we no longer need a
|
||||
// dedicated branch here. The keys UI lives inside GatewayServerForm
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HelpText } from './HelpText';
|
||||
|
||||
/**
|
||||
* Settings → System → Organizations (admin).
|
||||
*
|
||||
* Local organizations give local accounts a provider-agnostic 'org' visibility
|
||||
* scope (Gitea orgs come from Gitea instead). Uses /api/admin/orgs directly —
|
||||
* no config.yaml, no Save bar. See docs/superpowers/plans/2026-06-09-local-orgs.md.
|
||||
*/
|
||||
interface OrgMember { userId: string; role: string }
|
||||
interface LocalOrg { id: string; name: string; createdBy: string | null; createdAt: string; members: OrgMember[] }
|
||||
interface UserLite { id: string; email: string; name: string | null }
|
||||
|
||||
async function jget<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function OrgsForm() {
|
||||
const qc = useQueryClient();
|
||||
const orgsQ = useQuery<LocalOrg[]>({
|
||||
queryKey: ['admin', 'orgs'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/admin/orgs');
|
||||
if (res.status === 401 || res.status === 403) return [];
|
||||
if (!res.ok) throw new Error('failed');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
const usersQ = useQuery<UserLite[]>({
|
||||
queryKey: ['admin', 'users'],
|
||||
queryFn: () => jget<UserLite[]>('/api/admin/users'),
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['admin', 'orgs'] });
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const res = await fetch('/api/admin/orgs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
|
||||
if (!res.ok) throw new Error('create failed');
|
||||
},
|
||||
onSuccess: () => { setNewName(''); invalidate(); },
|
||||
});
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (id: string) => { await fetch(`/api/admin/orgs/${encodeURIComponent(id)}`, { method: 'DELETE' }); },
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const renameMut = useMutation({
|
||||
mutationFn: async ({ id, name }: { id: string; name: string }) => {
|
||||
await fetch(`/api/admin/orgs/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const addMemberMut = useMutation({
|
||||
mutationFn: async ({ id, userId }: { id: string; userId: string }) => {
|
||||
await fetch(`/api/admin/orgs/${encodeURIComponent(id)}/members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) });
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const removeMemberMut = useMutation({
|
||||
mutationFn: async ({ id, userId }: { id: string; userId: string }) => {
|
||||
await fetch(`/api/admin/orgs/${encodeURIComponent(id)}/members/${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const orgs = orgsQ.data ?? [];
|
||||
const users = usersQ.data ?? [];
|
||||
const userLabel = (id: string) => {
|
||||
const u = users.find(x => x.id === id);
|
||||
return u ? (u.name || u.email) : id;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-1">Organizations</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
ローカルアカウント向けの組織。タスク/スケジュールの可視性を <code>org</code> にして、
|
||||
同じ組織のメンバーと共有できます(Gitea 組織とは別系統)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={e => { e.preventDefault(); if (newName.trim()) createMut.mutate(newName.trim()); }}
|
||||
>
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="新しい組織名"
|
||||
className="flex-1 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"
|
||||
/>
|
||||
<button type="submit" disabled={!newName.trim() || createMut.isPending} className="px-3 h-9 rounded-md text-xs font-semibold bg-accent text-white disabled:opacity-50 hover:opacity-90 whitespace-nowrap">
|
||||
+ 組織を作成
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{orgsQ.isLoading && <div className="text-xs text-slate-500">読み込み中...</div>}
|
||||
{!orgsQ.isLoading && orgs.length === 0 && (
|
||||
<div className="text-xs text-slate-400 border border-dashed border-slate-200 rounded p-4 text-center">
|
||||
組織がありません。上で作成してください。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{orgs.map(org => (
|
||||
<OrgCard
|
||||
key={org.id}
|
||||
org={org}
|
||||
users={users}
|
||||
userLabel={userLabel}
|
||||
onRename={(name) => renameMut.mutate({ id: org.id, name })}
|
||||
onDelete={() => { if (confirm(`組織「${org.name}」を削除しますか?\nこの組織に共有されているタスク等は private に戻ります。`)) deleteMut.mutate(org.id); }}
|
||||
onAddMember={(userId) => addMemberMut.mutate({ id: org.id, userId })}
|
||||
onRemoveMember={(userId) => removeMemberMut.mutate({ id: org.id, userId })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<HelpText>メンバーを追加/削除すると、そのユーザーのセッションは無効化され、次回アクセス時に共有が反映されます。</HelpText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgCard({ org, users, userLabel, onRename, onDelete, onAddMember, onRemoveMember }: {
|
||||
org: LocalOrg;
|
||||
users: UserLite[];
|
||||
userLabel: (id: string) => string;
|
||||
onRename: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
onAddMember: (userId: string) => void;
|
||||
onRemoveMember: (userId: string) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(org.name);
|
||||
const memberIds = new Set(org.members.map(m => m.userId));
|
||||
const addable = users.filter(u => !memberIds.has(u.id));
|
||||
const [pick, setPick] = useState('');
|
||||
|
||||
return (
|
||||
<div className="border border-hairline rounded-lg p-4 bg-canvas">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onBlur={() => { if (name.trim() && name.trim() !== org.name) onRename(name.trim()); }}
|
||||
className="flex-1 h-8 px-2 text-[13px] font-medium border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-canvas"
|
||||
/>
|
||||
<button type="button" onClick={onDelete} className="px-2.5 h-8 rounded-md text-xs font-medium border border-red-200 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/15 whitespace-nowrap">
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-2xs text-slate-500 mb-1.5">メンバー({org.members.length})</div>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2.5">
|
||||
{org.members.length === 0 && <span className="text-2xs text-slate-400">メンバーがいません</span>}
|
||||
{org.members.map(m => (
|
||||
<span key={m.userId} className="inline-flex items-center gap-1.5 pl-2 pr-1 h-6 rounded border border-hairline bg-surface text-slate-700 text-2xs">
|
||||
{userLabel(m.userId)}
|
||||
{m.role === 'owner' && <span className="text-[9px] text-blue-600">owner</span>}
|
||||
<button type="button" onClick={() => onRemoveMember(m.userId)} title="削除" className="text-slate-400 hover:text-red-500 leading-none px-0.5">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={pick} onChange={e => setPick(e.target.value)} className="flex-1 h-8 px-2 text-xs border border-hairline rounded-md bg-canvas">
|
||||
<option value="">メンバーを追加...</option>
|
||||
{addable.map(u => <option key={u.id} value={u.id}>{u.name || u.email}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!pick}
|
||||
onClick={() => { if (pick) { onAddMember(pick); setPick(''); } }}
|
||||
className="px-3 h-8 rounded-md text-xs font-medium border border-accent/60 text-accent hover:bg-accent-soft disabled:opacity-40 whitespace-nowrap"
|
||||
>
|
||||
追加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ const CONFIG_GROUPS = [
|
||||
{ id: 'paths-storage', label: 'Paths & Storage' },
|
||||
{ id: 'execution', label: 'Execution' },
|
||||
{ id: 'auth', label: 'Authentication' },
|
||||
{ id: 'organizations', label: '🏢 Organizations' },
|
||||
{ id: 'push-notifications', label: 'Web Push (Server)' },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user