Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ SDK の役割:

```js
USSP.config.url("https://storage.example.com")
await USSP.init({ clientId: "appId" })
await USSP.init({ clientId: "com.example.billing" }) // = ClientSpace
await USSP.upload("memo.txt", blob)
```

Expand All @@ -149,8 +149,11 @@ SDK は最小限の依存で安全に扱えるようにする。
最低限の UI 機能:

* サーバー Storage Adapter 設定画面(Local/S3/R2/Drive など)
* OAuth クライアント登録・管理
* Namespace・Storage Policy 設定
* OAuth クライアント登録・管理(ClientID相当は `ClientSpace` として運用)
* OAuth 許可画面でサービスURLとClientSpaceをユーザーへ明示
* ユーザー管理
* 各種ログ確認
* Namespace・保存先ストレージ選択・Storage Policy 設定
* 使用状況・クォータ表示

---
Expand Down
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import AdaptersPage from "@/pages/adapters";
import NamespacesPage from "@/pages/namespaces";
import ClientsPage from "@/pages/clients";
import UsersPage from "@/pages/users";
import LogsPage from "@/pages/logs";
import NotFound from "@/pages/not-found";

function Router() {
Expand All @@ -25,6 +26,7 @@ function Router() {
<Route path="/namespaces" component={NamespacesPage} />
<Route path="/clients" component={ClientsPage} />
<Route path="/users" component={UsersPage} />
<Route path="/logs" component={LogsPage} />
<Route component={NotFound} />
</Switch>
</AppLayout>
Expand Down
4 changes: 3 additions & 1 deletion client/src/components/layout/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
KeyRound,
LayoutDashboard,
Settings2,
UserCog
UserCog,
ScrollText
} from "lucide-react";
import {
Sidebar,
Expand All @@ -28,6 +29,7 @@ const navItems = [
{ title: "ネームスペース", url: "/namespaces", icon: FolderTree },
{ title: "OAuthクライアント", url: "/clients", icon: KeyRound },
{ title: "ユーザー", url: "/users", icon: UserCog },
{ title: "各種ログ", url: "/logs", icon: ScrollText },
];

function AppSidebar() {
Expand Down
22 changes: 22 additions & 0 deletions client/src/hooks/use-admin-logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";

export interface AdminLogEntry {
timestamp: string;
method: string;
path: string;
statusCode: number;
durationMs: number;
response?: unknown;
}

export function useAdminLogs(limit = 100) {
return useQuery({
queryKey: ["/api/admin/logs", limit],
queryFn: async () => {
const res = await fetch(`/api/admin/logs?limit=${limit}`, { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch admin logs");
return (await res.json()) as AdminLogEntry[];
},
refetchInterval: 5000,
});
}
29 changes: 29 additions & 0 deletions client/src/hooks/use-namespaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,32 @@ export function useDeleteNamespace() {
}
});
}

export function useUpdateNamespace() {
const queryClient = useQueryClient();
const { toast } = useToast();

return useMutation({
mutationFn: async ({ id, data }: { id: number; data: { storageAdapterId?: number | null; quotaBytes?: number | null } }) => {
const url = buildUrl(api.namespaces.update.path, { id });
const res = await fetch(url, {
method: api.namespaces.update.method,
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(data),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Failed to update namespace" }));
throw new Error(err.message || "Failed to update namespace");
}
return api.namespaces.update.responses[200].parse(await res.json());
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [api.namespaces.list.path] });
toast({ title: "Namespace updated" });
},
onError: (err) => {
toast({ title: "Failed to update namespace", description: err.message, variant: "destructive" });
}
});
}
94 changes: 55 additions & 39 deletions client/src/pages/clients.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type { OauthClient } from "@shared/schema";

const clientFormSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
clientId: z.string().min(3, "ClientSpace は3文字以上で入力してください"),
redirectUris: z.string().min(5, "Provide at least one redirect URI"),
});

Expand All @@ -50,12 +51,12 @@ export default function ClientsPage() {
<KeyRound className="w-8 h-8 text-primary" />
OAuthクライアント
</h1>
<p className="text-muted-foreground mt-1">Manage applications authorized to use the USSP SDK.</p>
<p className="text-muted-foreground mt-1">サービス提供者が設定する ClientSpace と OAuth 設定を管理します。</p>
</div>
<CreateClientDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
on作成日時={(client) => setNewClientDetails(client)}
onCreated={(client) => setNewClientDetails(client)}
/>
</div>

Expand All @@ -65,7 +66,7 @@ export default function ClientsPage() {
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>App Name</TableHead>
<TableHead>クライアントID</TableHead>
<TableHead>ClientSpace</TableHead>
<TableHead>Redirect URIs</TableHead>
<TableHead>作成日時</TableHead>
<TableHead className="text-right">操作</TableHead>
Expand Down Expand Up @@ -122,7 +123,6 @@ export default function ClientsPage() {
</CardContent>
</Card>

{/* Secret Display Modal - Shown only immediately after creation */}
{newClientDetails && (
<SecretRevealDialog
client={newClientDetails}
Expand All @@ -136,48 +136,45 @@ export default function ClientsPage() {
function CreateClientDialog({
open,
onOpenChange,
on作成日時
onCreated
}: {
open: boolean,
onOpenChange: (open: boolean) => void,
on作成日時: (client: OauthClient) => void
onCreated: (client: OauthClient) => void
}) {
const createMutation = useCreateClient();

const form = useForm<ClientFormValues>({
resolver: zodResolver(clientFormSchema),
defaultValues: {
name: "",
clientId: "",
redirectUris: "http://localhost:3000/callback",
},
});

function onSubmit(data: ClientFormValues) {
createMutation.mutate(
data,
{
onSuccess: (newClient) => {
onOpenChange(false);
form.reset();
// Pass the returned client (which includes the unhashed secret) to parent
on作成日時(newClient as unknown as OauthClient);
}
createMutation.mutate(data, {
onSuccess: (newClient) => {
onOpenChange(false);
form.reset();
onCreated(newClient);
}
);
});
}

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="w-4 h-4" /> クライアント登録
<Plus className="w-4 h-4" /> OAuthクライアントを作成
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>OAuthクライアント登録</DialogTitle>
<DialogDescription>
Create credentials for a new application to use the USSP API.
ClientSpace はサービス提供者が任意に決める識別子です。OAuth 承認画面でユーザーへ明示されます。
</DialogDescription>
</DialogHeader>

Expand All @@ -188,23 +185,38 @@ function CreateClientDialog({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Application Name</FormLabel>
<FormLabel>App Name</FormLabel>
<FormControl>
<Input placeholder="e.g. My Next.js Frontend" {...field} />
<Input placeholder="e.g. My Web App" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>ClientSpace</FormLabel>
<FormControl>
<Input placeholder="e.g. com.example.billing" {...field} />
</FormControl>
<FormDescription>既存 ClientID 相当の値です。サービス提供者が独自設定してください。</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="redirectUris"
render={({ field }) => (
<FormItem>
<FormLabel>Redirect URIs (comma separated)</FormLabel>
<FormLabel>Redirect URI(s)</FormLabel>
<FormControl>
<Input placeholder="https://app.com/api/auth/callback" {...field} />
<Input placeholder="https://yourapp.com/callback" {...field} />
</FormControl>
<FormDescription>Where to send users after authorization.</FormDescription>
<FormMessage />
Expand All @@ -214,7 +226,7 @@ function CreateClientDialog({

<div className="pt-4 flex justify-end">
<Button type="submit" disabled={createMutation.isPending} className="w-full sm:w-auto">
{createMutation.isPending ? "生成中..." : "認証情報を生成"}
{createMutation.isPending ? "Creating..." : "Create Client"}
</Button>
</div>
</form>
Expand All @@ -228,47 +240,51 @@ function SecretRevealDialog({ client, onClose }: { client: OauthClient, onClose:
const [copiedId, setCopiedId] = useState(false);
const [copiedSecret, setCopiedSecret] = useState(false);

const copyToClipboard = (text: string, setter: (val: boolean) => void) => {
navigator.clipboard.writeText(text);
setter(true);
setTimeout(() => setter(false), 2000);
const copyToClipboard = async (text: string, setCopied: (val: boolean) => void) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// no-op
}
};

return (
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[500px] border-primary/20">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-primary">
<CheckCircle2 className="w-6 h-6" /> Client Registered
<CheckCircle2 className="w-5 h-5" /> OAuth Client created
</DialogTitle>
<DialogDescription>
Store these credentials securely. <strong className="text-destructive">The クライアントシークレット will never be shown again.</strong>
この情報は一度だけ表示されます。安全な場所に保存してください。
</DialogDescription>
</DialogHeader>

<div className="space-y-4 my-4 p-4 bg-muted/30 border border-border rounded-lg">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">クライアントID</label>
<div className="space-y-3 pt-2">
<div className="space-y-1">
<p className="text-sm font-medium">ClientSpace</p>
<div className="flex gap-2">
<Input readOnly value={client.clientId} className="font-mono bg-background text-sm" />
<Button size="icon" variant="outline" onClick={() => copyToClipboard(client.clientId, setCopiedId)}>
{copiedId ? <CheckCircle2 className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
{copiedId ? <CheckCircle2 className="w-4 h-4 text-primary" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">クライアントシークレット</label>

<div className="space-y-1">
<p className="text-sm font-medium">Client Secret</p>
<div className="flex gap-2">
<Input readOnly value={client.clientSecret} className="font-mono bg-background text-sm" />
<Button size="icon" variant="outline" onClick={() => copyToClipboard(client.clientSecret, setCopiedSecret)}>
{copiedSecret ? <CheckCircle2 className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
{copiedSecret ? <CheckCircle2 className="w-4 h-4 text-primary" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-2 p-3 bg-destructive/10 text-destructive text-sm rounded-md border border-destructive/20">

<div className="flex items-start gap-2 text-sm text-destructive bg-destructive/5 p-3 rounded-md border border-destructive/20">
<AlertTriangle className="w-5 h-5 shrink-0" />
<p>シークレットは今すぐコピーしてください。紛失した場合はクライアントを再作成してください。</p>
</div>
Expand Down
58 changes: 58 additions & 0 deletions client/src/pages/logs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useAdminLogs } from "@/hooks/use-admin-logs";
import { Card, CardContent } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { ScrollText } from "lucide-react";
import { format } from "date-fns";

export default function LogsPage() {
const { data: logs, isLoading } = useAdminLogs(150);

return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight text-foreground flex items-center gap-2">
<ScrollText className="w-8 h-8 text-primary" />
各種ログ確認
</h1>
<p className="text-muted-foreground mt-1">直近の API リクエストログを確認できます(自動更新)。</p>
</div>

<Card className="overflow-hidden">
<CardContent className="p-0">
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Method</TableHead>
<TableHead>Path</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">Loading logs...</TableCell>
</TableRow>
) : logs?.length ? (
logs.map((entry, idx) => (
<TableRow key={`${entry.timestamp}-${idx}`}>
<TableCell className="text-xs">{format(new Date(entry.timestamp), "yyyy-MM-dd HH:mm:ss")}</TableCell>
<TableCell className="font-mono text-xs">{entry.method}</TableCell>
<TableCell className="font-mono text-xs">{entry.path}</TableCell>
<TableCell className="text-xs">{entry.statusCode}</TableCell>
<TableCell className="text-xs">{entry.durationMs}ms</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">No logs</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
Loading