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

This commit is contained in:
oss-sync
2026-06-09 12:59:57 +00:00
parent 2ec9853655
commit 454d6f957b
12 changed files with 628 additions and 13 deletions
@@ -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
+185
View File
@@ -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)' },
],
},